FirstDevJob Docs
API Reference

Bookmarks API

Convex functions for saving jobs and tracking application progress

A bookmark and a tracked application are the same record: one row in the trackedApplications table, created the moment a user saves a job and carrying that job through saved → applied → interviewing → offer → accepted. All of it lives in convex/bookmarks.ts; the status rules it enforces live in convex/applicationIntegrity.ts. Ownership is checked on every write by comparing userId against the Clerk identity, so a bookmark id from another user is rejected rather than ignored.

The dashboard does not call the queries on this page. It reads bookmarks, role and pending jobs in a single query, dashboard.getDashboardData (convex/dashboard.ts), and uses the mutations here for writes. getUserBookmarks and getBookmarkStatus still exist and still work.

toggleBookmark

Saves a job, or un-saves it — but only while there is nothing to lose.

Kindmutation
AuthAuthenticated
ArgTypeRequired
jobIdId<"jobs">Yes

Returns: { bookmarked: boolean, tracked: boolean }

ReturnMeaning
{ bookmarked: true, tracked: false }A new row was created with status saved
{ bookmarked: false, tracked: false }An untouched saved row was deleted
{ bookmarked: true, tracked: true }Nothing was deleted: the row has progress, so it was kept

A row counts as having progress when its status is anything other than saved, or when its notes contain non-whitespace text. In that case the toggle is a no-op and returns tracked: true so the caller can explain why the job stayed saved — deleting an application history from a one-click toggle on a list page is never correct. Removing a tracked application is a separate, explicit action: the "Remove from tracker" button on the dashboard card, which calls removeTrackedApplication.

New rows are created with a jobSnapshot (a copy of the job's title, company, location, url, creation time, tags and role level) and a statusHistory seeded with a single saved entry.

Throws: Not authenticated, Job not found.

const toggleBookmark = useMutation(api.bookmarks.toggleBookmark);

const result = await toggleBookmark({ jobId: job._id });
if (result.bookmarked && result.tracked) {
  // Kept: this job is being tracked on the dashboard.
}

updateBookmarkStatus

The mutation that powers application tracking. It writes the status, optionally the notes, and appends to statusHistory whenever the status actually changes.

Kindmutation
AuthAuthenticated, and must own the row
ArgTypeRequiredNotes
bookmarkIdId<"trackedApplications">Yes
status"saved" | "applied" | "interviewing" | "offer" | "rejected" | "accepted"YesMust be the current status or an allowed transition
notesstringNoTrimmed, max 2000 characters. Omitting it leaves existing notes alone

Returns: { success: true }

Throws:

ErrorCause
Not authenticatedNo Clerk identity
Bookmark not foundNo row with that id
UnauthorizedThe row belongs to another user
Invalid status transition from X to Y.Not in the transition table below
Notes must be 2000 characters or fewer.notes exceeds MAX_NOTES_LENGTH after trimming

Passing the current status is always allowed — that is how a notes-only edit is saved, and it does not append a history entry.

const updateBookmarkStatus = useMutation(api.bookmarks.updateBookmarkStatus);

await updateBookmarkStatus({ bookmarkId, status: "applied" });
await updateBookmarkStatus({ bookmarkId, status: "applied", notes: "Phone screen booked" });

Allowed status transitions

From allowedStatusTransitions in convex/applicationIntegrity.ts. Forward progress, plus a way back out of the two end states so a mis-click on Rejected or Accepted is recoverable without deleting the record.

FromAllowed next statuses
savedapplied, interviewing, rejected
appliedinterviewing, offer, rejected
interviewingoffer, rejected
offeraccepted, rejected
rejectedsaved, applied, interviewing, offer
acceptedoffer, rejected
Application status transitions enforced by updateBookmarkStatus
Diagram source
stateDiagram-v2
[*] --> saved : toggleBookmark
saved --> applied
saved --> interviewing
saved --> rejected
applied --> interviewing
applied --> offer
applied --> rejected
interviewing --> offer
interviewing --> rejected
offer --> accepted
offer --> rejected
accepted --> offer
accepted --> rejected
rejected --> saved
rejected --> applied
rejected --> interviewing
rejected --> offer

Note what is not there: saved cannot jump straight to offer or accepted, interviewing cannot fall back to applied, and nothing can move to saved except from rejected.

The dashboard's status dropdown is built from the same table — DashboardJobCard imports getAllowedStatusTransitions directly from convex/applicationIntegrity.ts, so the menu only ever offers transitions the server will accept.

removeTrackedApplication

Deletes a tracked application outright, including its status history and notes. This is the "Remove from tracker" button on the dashboard card, and it is the only way to delete a row that has progress on it.

Kindmutation
AuthAuthenticated, and must own the row
ArgTypeRequired
bookmarkIdId<"trackedApplications">Yes

Returns: { success: true }

Throws: Not authenticated, Bookmark not found, Unauthorized.

getBookmarkStatus

Kindquery
AuthAnonymous allowed
ArgTypeRequired
jobIdId<"jobs">Yes

Returns: { bookmarked: boolean }. Signed-out callers always get { bookmarked: false } rather than an error.

The job list does not need this query: jobs.listApprovedJobs already returns an isBookmarked flag per job for authenticated callers.

getUserBookmarks

Kindquery
AuthAnonymous allowed — returns [] when signed out
Argsnone

Returns: an array, newest first, of:

FieldTypeNotes
idId<"trackedApplications">Pass this as bookmarkId to the mutations
statusapplication status
notesstring | null
jobobjectid, title, company, location, url, createdAt, tags, roleLevel, availability, closureReason

job is resolved in one of three ways:

Source jobavailabilityclosureReason
Still exists, status is not outdatedactivenull
Still exists, status is outdatedclosedoutdated
Deleted, but the row has a jobSnapshotclosedremoved

A row whose job was deleted and which has no jobSnapshot is dropped from the result. dashboard.getDashboardData returns bookmarks in exactly this shape.

backfillTrackedApplicationSnapshots

KindinternalMutation — not callable from the client
Argsnone

Fills in jobSnapshot for older rows created before snapshots existed, skipping rows that already have one and rows whose job is already gone. Returns { updatedCount }. Run it from the Convex dashboard or CLI, not from the app.

On this page