FirstDevJob Docs
API Reference

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.

SituationError thrown
No Clerk identity on the requestNot authenticated
No profile row, or role is userUnauthorized

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

Kindquery
AuthAnonymous allowed
Argsnone

Returns:

CallerShape
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

Kindquery
AuthModerator or admin
Argsnone

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

Kindquery
AuthModerator or admin
Argsnone

Returns: a number — the length of the pending queue. Currently unused by the app.

getApprovedJobs

Kindquery
AuthModerator or admin
Argsnone

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.

Kindmutation
AuthModerator or admin
ArgTypeRequired
jobIdId<"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 roleFilters include the job's roleLevel (an empty filter list means all role levels);
  • skips any subscriber who already has a jobNotificationDeliveries row 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.

Kindmutation
AuthModerator or admin
ArgTypeRequired
jobIdId<"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.

Kindmutation
AuthModerator or admin
ArgTypeRequired
jobIdId<"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

SurfaceFunction
Pending queue and role check on /dashboarddashboard.getDashboardData
Approved listings paneladmin.getApprovedJobs
Approve / Reject buttonsadmin.updateJobStatus
Mark outdatedadmin.markJobOutdated
Deleteadmin.deleteJob
Staff email when a job is submittedinternal.notifications.sendNewSubmissionStaffEmail, scheduled by jobs.postJob

On this page