FirstDevJob Docs
API Reference

Jobs API

Convex functions for listing, filtering and submitting job postings

Everything a job posting does — being listed, being submitted, aging out — is in convex/jobs.ts, with submission validation split into convex/jobSubmissionSecurity.ts and role-level inference into convex/jobHelpers.ts. Two rules shape the whole module: the public list only ever contains jobs whose status is approved, and everything a user submits starts at pending and stays invisible until a moderator approves it through the Admin API.

listApprovedJobs

The query behind the home page listing.

Kindquery
AuthAnonymous allowed
ArgTypeRequiredBehaviour
searchTermstringNoCase-insensitive substring match against title, company or location
roleLevel"intern" | "graduate" | "earlyCareer"NoExact match
locationstringNoExact match on the trimmed location string, not a substring

Returns: an array, newest first, of:

FieldTypeNotes
_idId<"jobs">
_creationTimenumberConvex-managed epoch ms
titlestring
companystring
locationstring"" when unset
urlstring"" when unset
statusstringAlways "approved" here
tagsstring[][] when unset
roleLevelrole level or undefined
isBookmarkedbooleanAlways false for signed-out callers

roleLevel and location are served by indexes — the query picks by_status_roleLevel_location, by_status_roleLevel, by_status_location or by_status depending on which filters are present. searchTerm is applied in memory after the index read, so it never widens the set beyond what the other filters returned.

const jobs = useQuery(api.jobs.listApprovedJobs, {
  searchTerm: "frontend",
  roleLevel: "intern",
  location: "Remote",
});

getApprovedJobFilterOptions

Kindquery
AuthAnonymous allowed
Argsnone

Returns: { locations: string[] } — the distinct, trimmed, non-empty locations across approved jobs, sorted alphabetically. Populates the location dropdown so it can never offer a filter value that matches nothing.

postJob

Submits a job for moderation. The record is inserted with status: "pending" and submitterUserId set to the caller's Clerk id; it does not appear in listApprovedJobs until a moderator approves it.

Kindmutation
AuthAuthenticated
ArgTypeRequiredLimit
titlestringYes120 characters after trimming
companystringYes120 characters after trimming
locationstringYes120 characters after trimming
urlstringYes2048 characters, http/https only
roleLevel"intern" | "graduate" | "earlyCareer"Yes
tagsstring[]NoUp to 10 tags, 32 characters each

Returns: { success: true, jobId, message }, where message is the reviewed-within-24-hours confirmation shown in the submit modal.

const postJob = useMutation(api.jobs.postJob);

const result = await postJob({
  title: "Junior Frontend Developer",
  company: "Acme Inc",
  location: "Remote",
  url: "https://acme.example/careers/123",
  roleLevel: "earlyCareer",
  tags: ["React", "TypeScript"],
});

Validation

validateAndNormalizeJobSubmission runs before anything is written. All limits come from the constants at the top of convex/jobSubmissionSecurity.ts.

Text fields (title, company, location) are trimmed, must be non-empty, must be within their length cap, and must contain no ASCII control characters.

The URL is checked harder, because it is a link the whole site will click:

CheckError thrown
Non-empty after trimmingURL is required
At most 2048 charactersURL must be 2048 characters or fewer
No control charactersURL contains invalid characters
Parses as a URLPlease enter a valid URL
Scheme is http: or https:URL must start with http:// or https://
No embedded credentialsURL must not include username or password
Non-empty hostnameURL must include a valid hostname
Not a local or private hostLocal or private network URLs are not allowed

The private-host check rejects localhost, ::1, any .local or .internal suffix, IPv6 unique-local (fc/fd) and link-local (fe8–feb) prefixes, and the IPv4 ranges 0.0.0.0/8, 10/8, 127/8, 169.254/16, 172.16–172.31, and 192.168/16. The stored url is the normalized URL.toString() output, not the raw input.

Tags are rejected wholesale if there are more than 10 or if any contains control characters; individually they are trimmed, dropped when empty, capped at 32 characters, and de-duplicated case-insensitively (the first casing seen wins). Errors: You can add up to 10 tags, Tags contain invalid characters, Each tag must be 32 characters or fewer.

src/lib/postJobError.ts maps these server messages to the friendlier copy shown in the submit modal, so changing an error string here means updating that mapping too. It is covered by tests/lib/postJobError.test.ts and the validator itself by tests/convex/jobSubmissionSecurity.test.ts.

Rate limits

Counted per Clerk user over the by_submitterUserId index, across every submission regardless of its status.

WindowMaximumError thrown
1 hour6 submissionsRate limit reached. Please wait before submitting another job.
24 hours20 submissionsDaily submission limit reached. Please try again tomorrow.

Side effects

  1. Any tag that is not already in the tags table is inserted, so the tag filter list grows with real submissions.
  2. internal.notifications.sendNewSubmissionStaffEmail is scheduled with the new jobId. Scheduling failures are logged and swallowed — a broken email configuration never fails a submission. See Email Subscriptions API.

Throws: Not authenticated, plus any validation or rate-limit error above.

Role levels

roleLevel is one of intern, graduate or earlyCareer, and it drives both the filters and the per-role email notification filters.

inferRoleLevelFromTitle in convex/jobHelpers.ts derives it from the job title, lowercased:

ResultTriggered by a title containing
interninternship, intern, co-op, co op, placement, trainee, apprentice
graduategraduate, new grad
earlyCareeranything else (the fallback)

Inference is not applied to new submissions. postJob requires an explicit roleLevel from the submitter; inferRoleLevelFromTitle is only used by the backfillJobRoleLevelsFromTitle migration below.

Internal functions

Not callable from the client — they have no entry in the public api object and must be run from the Convex dashboard, the CLI, or the cron scheduler.

deleteOldJobs

KindinternalMutation
ScheduleDaily at 00:00 UTC, registered as cleanup old jobs in convex/crons.ts
Argsnone

Per run, in order:

StepRule
Delete stale submissionspending and rejected jobs created more than 14 days ago
Delete expired closuresoutdated jobs created more than 30 days ago
Close aged listingsapproved jobs created more than 14 days ago become outdated

Expired outdated jobs are deleted before new ones are marked, so a job is never marked outdated and hard-deleted in the same run. Deleting a job also deletes its jobNotificationDeliveries rows. Each step takes at most 100 jobs; if any step fills its batch the mutation reschedules itself immediately to work through the backlog, which keeps each transaction inside Convex's limits.

Returns: { deletedCount, markedOutdatedCount }.

A deleted job does not vanish from a user's dashboard — the tracked application falls back to its jobSnapshot and is shown as closed. See Bookmarks API.

backfillJobRoleLevelsFromTitle

KindinternalMutation
Argsnone

One-off migration: sets roleLevel on every job that is missing one, using inferRoleLevelFromTitle. Jobs that already have a role level are skipped. Returns { updatedCount }.

On this page