Admin API
Convex functions for moderating job submissions
convex/admin.ts is the moderation queue: read what is waiting, approve or reject it, close listings that have gone stale, and delete outright. Every function except checkUserRole is gated by the same helper, and approval is the one action in the whole backend that sends email to users.
Authorization
requireModOrAdmin runs first in every gated function. It resolves the Clerk identity, loads the caller's profiles row, and requires role to be moderator or admin.
| Situation | Error thrown |
|---|---|
| No Clerk identity on the request | Not authenticated |
No profile row, or role is user | Unauthorized |
admin and moderator have identical backend permissions — nothing in convex/admin.ts distinguishes them. The split only exists in the UI. Roles are set by editing the profiles row in the Convex dashboard; no function promotes a user.
checkUserRole
| Kind | query |
| Auth | Anonymous allowed |
| Args | none |
Returns:
| Caller | Shape |
|---|---|
| Signed out | { isAdmin: false, isModerator: false } |
| Signed in, no profile row | { isAdmin: false, isModerator: false, userEmail } |
| Signed in with a profile | { isAdmin, isModerator, userEmail, role } |
isModerator is true for admins as well as moderators, so it is the flag to branch on for moderation UI.
The app does not call this query. dashboard.getDashboardData returns the same object as its userRole field, alongside the user's bookmarks and the pending queue, in one round trip.
getPendingJobs
| Kind | query |
| Auth | Moderator or admin |
| Args | none |
Returns: every job with status: "pending", newest first, as { id, createdAt, title, company, location, url, status, tags }. Note id and createdAt, not the raw _id and _creationTime.
Superseded in the app by the pendingJobs field of dashboard.getDashboardData, which returns the identical shape and is empty for non-moderators.
getPendingJobsCount
| Kind | query |
| Auth | Moderator or admin |
| Args | none |
Returns: a number — the length of the pending queue. Currently unused by the app.
getApprovedJobs
| Kind | query |
| Auth | Moderator or admin |
| Args | none |
Returns: every approved job, newest first, in the same shape as getPendingJobs. This is the moderator's view of live listings in AdminSection, and unlike jobs.listApprovedJobs it carries no bookmark state and applies no filters.
updateJobStatus
Approves or rejects a submission.
| Kind | mutation |
| Auth | Moderator or admin |
| Arg | Type | Required |
|---|---|---|
jobId | Id<"jobs"> | Yes |
status | "approved" | "rejected" | Yes |
Returns: { success: true }
Throws: Not authenticated, Unauthorized, Job not found.
const updateJobStatus = useMutation(api.admin.updateJobStatus);
await updateJobStatus({ jobId, status: "approved" });Approving flips the job into listApprovedJobs immediately — Convex pushes the new result to every subscribed client without a refetch.
Email triggered on approval
When the previous status was not already approved and the new status is approved, the mutation schedules internal.notifications.sendJobApprovedUserEmails for that job. That action:
- re-reads the job and stops unless it is still
approved; - fetches active subscribers whose
roleFiltersinclude the job'sroleLevel(an empty filter list means all role levels); - skips any subscriber who already has a
jobNotificationDeliveriesrow for this job, and records one after each successful send.
Rejecting sends nothing. Re-approving an already-approved job sends nothing, because the guard compares against the previous status. Details in Email Subscriptions API.
There is no email to the submitter and no rejection reason field. A rejected job is simply removed from the queue and deleted by the retention cron 14 days after it was created.
markJobOutdated
Closes a listing without destroying anything.
| Kind | mutation |
| Auth | Moderator or admin |
| Arg | Type | Required |
|---|---|---|
jobId | Id<"jobs"> | Yes |
Returns: { success: true }
The job leaves the public list, and anyone tracking it keeps the record: their dashboard card shows availability: "closed" with closureReason: "outdated". The retention cron deletes outdated jobs 30 days after their creation time — and applies the same status automatically to approved jobs older than 14 days.
Sends no email.
deleteJob
Permanently deletes the job document.
| Kind | mutation |
| Auth | Moderator or admin |
| Arg | Type | Required |
|---|---|---|
jobId | Id<"jobs"> | Yes |
Returns: { success: true }
Tracked applications pointing at the job are not deleted; they fall back to their jobSnapshot and render as closed with closureReason: "removed". Sends no email.
Unlike the retention cron, this mutation does not clean up the job's jobNotificationDeliveries rows, so those delivery receipts are left orphaned. They are harmless (deduplication keys for a job that no longer exists) but they are never reclaimed unless the affected user deletes their account. Prefer markJobOutdated for ordinary closures.
Where moderation happens
| Surface | Function |
|---|---|
Pending queue and role check on /dashboard | dashboard.getDashboardData |
| Approved listings panel | admin.getApprovedJobs |
| Approve / Reject buttons | admin.updateJobStatus |
| Mark outdated | admin.markJobOutdated |
| Delete | admin.deleteJob |
| Staff email when a job is submitted | internal.notifications.sendNewSubmissionStaffEmail, scheduled by jobs.postJob |