Database & Prisma
All schema, migrations, and the generated client live in packages/database. The database is the canonical source of truth for the platform — every app reads its types from @repo/database and never re-declares them.
Conventions
Section titled “Conventions”- Schema source:
packages/database/prisma/schema.prisma. - Migrations:
packages/database/prisma/migrations/<YYYYMMDDHHmmss>_<snake_case>/. - Generated client is the Prisma client — apps import it via
@repo/database. - New models follow the existing style:
id String @id @default(cuid()),createdAt DateTime @default(now()),updatedAt DateTime @updatedAt, and indexes on every column commonly used for filtering or scoping (especiallyorganizationId). - Soft / “legacy” columns are kept around with a comment when a structured replacement supersedes them; the rename is sequenced over multiple migrations.
Models grouped by domain
Section titled “Models grouped by domain”Organizations & identity
Section titled “Organizations & identity”Organization— a tenant. Owns candidate profiles (when staffed), job postings, applications, and audit logs.WorkOsUser— local mirror of WorkOS user metadata, populated byuser.created/user.updatedwebhooks. Used for relational integrity when local rows need a foreign key to a user.
Candidates
Section titled “Candidates”CandidateProfile— the canonical candidate record. Owns resume, skills, work prefs, links, and discoverability state.CandidateFileObject— S3-backed file references for profile picture and resume.DocumentType/DocumentVariant— configurable required document definitions and selectable form variants.DocumentTemplateVersion— S3-backed blank form revisions for each document variant.CandidateDocument— S3-backed signed candidate uploads and Teros Ops review state.ResumeConversion— async resume → markdown extraction state and result.PublicResumeLink— token-gated shareable URL for a candidate’s resume.CandidateInvitation— token-gated invite for a candidate to claim or onboard.
Job postings
Section titled “Job postings”JobPosting— an open role at an organization.JobPostingPublicLink— token-gated public URL to a job posting.JobPostingRequest— the company intake form (a request that produces aJobPostingonce submitted).
Applications & pipeline
Section titled “Applications & pipeline”Application— a candidate’s application to a posting; unique per(jobPostingId, candidateProfileId).ApplicationStageEvent— every advance/reject/withdraw event, with actor and timestamp.CandidateNote— internal / application-scoped notes left on candidates.
Skills
Section titled “Skills”NormalizedSkill— canonical skill registry with parent/child grouping and approval state.NormalizedSkillSource— link table between aCandidateProfile’s raw skill string and aNormalizedSkill.
AuditLog— append-only record of privileged actions, indexed byorganizationId,actorSub,action,targetType, andcreatedAt.
Adding a model
Section titled “Adding a model”- Edit
packages/database/prisma/schema.prisma. - Run
pnpm run db:migrate:devfrom the repo root (ornpx prisma migrate devfrompackages/database). - Commit the generated migration in
packages/database/prisma/migrations/. - Regenerate clients via
pnpm run db:generate. - If the change renames or removes a field, update every consuming app in the same PR.
Field-level read/write permissions
Section titled “Field-level read/write permissions”For the most permission-sensitive models, the matrix below documents who can read and who can write each field. Until field-level extraction is automated, this is hand-maintained and should be updated when a service changes its field filtering.
CandidateProfile
Section titled “CandidateProfile”| Field | Read | Write |
|---|---|---|
firstName, lastName, headline, bio, location* |
owner member, company actors w/ app context, teros-ops* |
owner member, teros-ops* |
skills, workPreferences, portfolioLinks, yearsOfExperience |
same as above | same as above |
linkedinUrl, githubUrl |
same as above | same as above |
phoneNumber, phoneCountryCode |
owner member, teros-ops* |
owner member, teros-ops* |
profileImage*, resume* |
owner member, company actors w/ app context, teros-ops* |
owner member, teros-ops* |
isDiscoverable |
owner member, teros-ops* |
owner member, teros-ops* |
approvalStatus, approvedAt, approvedByWorkosUserSub |
teros-ops* |
teros-ops* |
salaryExpectation*, currentSalary |
owner member, teros-ops* |
owner member, teros-ops* |
lastActivityAt |
owner member, company actors w/ app context, teros-ops* |
system (Postgres triggers) |
Application
Section titled “Application”| Field | Read | Write |
|---|---|---|
coverNote |
candidate (own), company actors of posting org, teros-ops* |
candidate at submission only |
status |
candidate (own), company actors, teros-ops* |
company-admin, teros-ops*; company-member limited |
offerStartDate, offeredSalary |
candidate (own), company-admin, teros-ops* |
company-admin, teros-ops* |
withdrawnAt |
candidate (own), company actors, teros-ops* |
candidate (own withdraw), teros-ops* |
terminatedAt |
company-admin, teros-ops* |
company-admin, teros-ops* |
JobPosting
Section titled “JobPosting”| Field | Read | Write |
|---|---|---|
title, description, location*, employmentType, isRemote, currency |
public (via public link or list), all roles | company-admin, teros-ops* |
minCompensation, maxCompensation |
public listing, all internal roles | company-admin, teros-ops* |
status, openedAt, closedAt |
public listing, all internal roles | company-admin, teros-ops* |
createdBySub, updatedBySub |
company-admin, teros-ops* |
system-managed |
AuditLog
Section titled “AuditLog”| Field | Read | Write |
|---|---|---|
action, actorSub, targetType, targetId, createdAt, metadata |
company-admin (scoped to own organizationId), teros-ops* (cross-org) |
system |
organizationId, actorOrgId |
same as above | system |
CandidateNote
Section titled “CandidateNote”| Field | Read | Write |
|---|---|---|
content (visibility = INTERNAL) |
teros-ops* only |
teros-ops* |
content (visibility = APPLICATION) |
company actors of the posting org, teros-ops* |
company actors of the posting org, teros-ops* |
authorSub |
same as content for that visibility |
system-managed |
Documents
Section titled “Documents”| Model / Field | Read | Write |
|---|---|---|
DocumentType, DocumentVariant public config fields |
owner member, teros-ops* |
seed / system config |
DocumentTemplateVersion bucket/object metadata |
owner member, teros-ops* |
seed / system config |
CandidateDocument upload metadata, status, expiry, rejection reason |
owner member, teros-ops* |
owner member creates uploads |
CandidateDocument.reviewedByWorkosUserSub, reviewedAt, status, rejectionReason |
owner member, teros-ops* |
teros-ops* review decisions |
Organization
Section titled “Organization”| Field | Read | Write |
|---|---|---|
name, slug, description |
members of org, teros-ops* |
company-admin (own org), teros-ops* |
workosOrganizationId, ownerWorkosUserId |
teros-ops* |
system-managed (WorkOS sync) |
Documents module registration
Section titled “Documents module registration”The Documents module is generic. Platform gating reads active DocumentType rows, resolves their requiredPredicate, and checks whether the candidate has a valid CandidateDocument. The gate does not branch on tax-specific ids.
To add a new required document type:
- Add
document_typesanddocument_variantsseed rows. - Add a
document_template_versionsrow pointing at the blank PDF in S3. - Register a
ValidityPolicyorRequiredPredicatekey only if the built-inneverexpiry orLINKED_TO_COMPANYpredicate is not enough. - Keep
blocksPayment=falseunless that document should affect the informational payment eligibility endpoint.
CandidateProfile.lastActivityAt
Section titled “CandidateProfile.lastActivityAt”lastActivityAt is a denormalized “last activity” timestamp on CandidateProfile, maintained entirely by Postgres triggers — no application code path writes to it. Drives the default ops Pool page ordering.
Activity sources (rows whose INSERT/UPDATE bump lastActivityAt = now() on the related profile):
"CandidateProfile"itself: any meaningful column change (excludeslastActivityAt,updatedAt,searchVector)."Application","ApplicationStageEvent","CandidateNote".candidate_documents."PublicResumeLink","CandidateInvitation","JobPostingCandidateSuggestionFeedback".
Explicit exclusions (no triggers attached): "CandidateProfileEmbedding", "NormalizedSkillSource", "ResumeConversion".
Adding a new activity source = follow-up migration attaching the shared candidate_profile_bump_last_activity_from_related(<fk-column>) trigger function. Data-fix or backfill scripts that should not count as activity must run with SET session_replication_role = replica for the session so the triggers are skipped.
See also
Section titled “See also”- API — how services enforce these access decisions.
- Migrations — the loop for changing the schema safely.
- Permissions Matrix — endpoint-level view of the same model.