GraphQL API
The Aurea GraphQL API — schema, queries, mutations, and how to connect from the dashboard or any GraphQL client.
Endpoint
POST http://localhost:4000/graphqlThe Apollo Sandbox playground is available at the same URL in a browser during development.
Authentication
Every query and mutation requires a verified Clerk session token:
Authorization: Bearer <clerk-session-token>Unauthenticated requests receive an UNAUTHENTICATED GraphQL error (401). Agency-only
operations (e.g. advanceCaseStatus) additionally require the agency_member role - the API
resolves the signed-in user's role from the local users row on each request. Role scoping of
row sets (clients see only their own rows) is applied server-side in the services.
Subscriptions
Subscriptions ride a graphql-ws WebSocket at ws://localhost:4000/graphql. The socket is
authenticated at connection time: pass the Clerk token in connectionParams as
{ "Authorization": "Bearer <token>" }. Sockets without a valid token are closed with
4403: Forbidden before any operation runs. A transport probe is available:
subscription {
ping
}The first domain subscription is messageAdded(conversationId: ID!) - see Realtime Messaging
below. New subscriptions follow the same WS auth pattern.
Schema
Types
Case
Represents an active immigration or relocation case managed by Aurea.
Prop
Type
CaseStatusEvent
One row per status transition, appended by advanceCaseStatus / createCase.
Prop
Type
Client
The agency's CRM record of a client relationship. A Client is not a login: userId links
to the Clerk-backed users row once (if ever) the person signs up — a lead has no login yet.
The whole CRM is agency-only (agency_member role required on every query and mutation).
Prop
Type
Select cases only in single-record client(id:) queries — it resolves per parent row, so
including it in a clients list query triggers one extra query per client (N+1).
ClientStats
Aggregate CRM counts powering the clients page KPI strip: total: Int!, active: Int!,
newThisMonth: Int! (created since the start of the current month), leads: Int!.
Task
An internal agency work item, optionally linked to a case and/or a CRM client (both nullable).
Tasks are agency-only (agency_member role required on every query and mutation).
assignee/case/client come pre-joined from a single query — selecting them does not fan
out per row.
Prop
Type
TaskStats
Aggregate org-wide task counts powering the tasks page KPI strip: overdue: Int! (due before
today, not done), dueToday: Int!, inProgress: Int!, completedThisWeek: Int! (done since
Monday, by updatedAt). Day boundaries are computed in UTC.
Lead
A sales lead on the agency pipeline kanban. Leads have no login — clientName is a plain
display string. The whole pipeline is agency-only (agency_member role required on every query
and mutation).
Prop
Type
PipelineTemplate
A service pipeline template configured in the agency settings builder, with nested stages, document checklists, and government references. Agency-only.
Prop
Type
PipelineStage (builder stage — distinct from the kanban PipelineStageId enum): id: ID!,
title: String!, description: String, position: Int! (0-indexed display order),
documents: [PipelineDocument!]!.
PipelineDocument: id: ID!, name: String!, required: Boolean!, govRefId: ID (linked
government reference, when any).
GovReference: id: ID!, authority: String!, title: String!, url: String!,
lastSyncedAt: DateTime (last sync run), hasUpdates: Boolean! (a tracked source changed since the
last review), lastSyncError: String (soft-failure message from the last failed fetch, else null),
acknowledgedAt: DateTime (when an agency member last cleared the update flag). Kept live by the
nightly GovReferenceSyncService cron + the syncGovReferences mutation (plan 17).
Document
A client document tracked through the request → upload → review workflow. Postgres holds
metadata only — file bytes live in S3-compatible object storage behind short-lived presigned
URLs (see the documents mutations). Reads are
role-scoped server-side: a client sees only their own documents; an agency_member sees all.
The storage key is never exposed — only the derived fileName.
Prop
Type
PresignedUrl: url: String!, expiresAt: DateTime! — a short-lived (~5 min) signed URL for a
direct browser-to-storage transfer.
UploadTicket: documentId: ID!, url: String!, expiresAt: DateTime! — the signed PUT issued
by createUploadUrl. The storage key stays server-internal.
LeadPriority enum
LOW | MEDIUM | HIGH
PipelineStageId enum
Kanban stages, in board order: NEW_LEAD, CONTACTED, CONSULTATION, PROPOSAL_SENT, WON,
LOST.
PipelineStatus enum
ACTIVE | DRAFT
ClientStatus enum
| Value | Description |
|---|---|
LEAD | Prospect — no engagement yet |
ACTIVE | Engaged client with ongoing work |
ON_HOLD | Engagement paused |
CLOSED | Relationship concluded |
TaskStatus enum
TODO | IN_PROGRESS | BLOCKED | DONE
TaskPriority enum
LOW | MEDIUM | HIGH
TaskScope enum
| Value | Description |
|---|---|
MY_TASKS | Only tasks assigned to the signed-in member |
TEAM | All agency tasks |
CaseStatus enum
| Value | Description |
|---|---|
INTAKE | Initial intake — case opened |
DOCUMENTS_PENDING | Waiting for client documents |
SUBMITTED | Documents submitted to authority |
IN_REVIEW | Under review by authority |
APPROVED | Case approved |
REJECTED | Case rejected |
ImmigrationService enum
| Value | Label |
|---|---|
US_VISA | US Visa Application |
DIGITAL_NOMAD_VISA | Digital Nomad Visa |
CITIZENSHIP_APPLICATION | Citizenship Application |
SPANISH_DRIVERS_LICENSE | Spanish Driver's License Exchange |
ReportRange enum
Time window for the agencyReports query.
| Value | Description |
|---|---|
SEVEN_DAYS | Rolling 7 days ending now |
THIRTY_DAYS | Rolling 30 days ending now (default) |
QTD | Quarter-to-date (first day of the current calendar quarter) |
YTD | Year-to-date (January 1 of the current calendar year) |
DocumentStatus enum
| Value | Description |
|---|---|
requested | Agency asked for it — no file yet |
uploaded | Bytes confirmed in storage (HeadObject-verified) |
in_review | Agency is reviewing |
approved | Accepted |
rejected | Sent back — note carries the feedback |
Service
An Aurea-curated catalog service, offered per country. The catalog is Aurea's product surface:
staff (super_admin) curate it, agencies select from it, clients complete an intake against it.
There is deliberately no price or tier field — what an agency charges a client is per-invoice
(client invoicing plan), never part of the catalog.
Prop
Type
ServiceQuestion
One intake questionnaire question defined on a catalog service: section, label, helpText,
type (QuestionType: TEXT | TEXTAREA | SELECT | MULTISELECT | DATE | NUMBER | BOOLEAN | FILE),
options (select choices), isRequired, sortOrder.
ServiceDocumentRequirement
A document a service requires from the client, tagged with a classification
(OFFICIAL | OFFICIAL_REQUIRED | NORMAL). OFFICIAL* rows are government-mandated:
read-only for agencies (the server rejects edits/overrides) and kept current by the
gov-reference sync plan. NORMAL rows are agency-editable. The service's authoritative sources
are linked at the service level via govReferences; isRequired is an independent axis the
server coerces from classification.
AgencyReports
The bundled analytics aggregate returned by agencyReports. All six fields are computed
server-side in one round-trip; the result is cached in memory for 60 seconds per range key.
Agency members only (agency_member role required -- a client token receives FORBIDDEN).
| Field | Type | Description |
|---|---|---|
kpis | [Kpi!]! | Active cases, avg response (plan-12-followup), capacity (plan-11), revenue (plan-18) |
caseVolume | [DataPoint!]! | Monthly case counts within the selected range (label + value) |
revenueTrend | [DataPoint!]! | Monthly revenue (always empty until plan-18 Stripe Connect lands) |
casesByService | [DataPoint!]! | Case count per ImmigrationService value |
funnel | [DataPoint!]! | Four fixed stages: Leads, Consultations, Proposals, Won |
teamPerformance | [TeamPerformance!]! | Per-agency-member active case count and win rate |
Kpi: label: String!, value: String!, trend: String!.
DataPoint: label: String!, value: Int!.
TeamPerformance: name: String!, activeCases: Int!, closed: Int!, winRate: Int!,
avgResponse: Float (null until plan-12-followup), capacityPct: Int (null until plan-11).
AgencyServiceConfig
A catalog service from the agency's point of view: serviceId, the nested service, isOffered,
pipelineTemplateId (the pipeline this service feeds), defaultFee (internal invoicing hint —
never client-visible), customQuestions: [AgencyServiceQuestion!]! (agency-added intake
questions), and hiddenRequirementIds: [ID!]! (optional catalog documents this agency hides).
Intake
A client's intake for one case: the effective questionnaire (catalog + agency custom
questions, with hidden optional documents removed and every legal document always present), the
saved answers, and the documents checklist. status is DRAFT | SUBMITTED
(IntakeStatus); answers reference questions by questionSource
(QuestionSource: CATALOG | AGENCY) + questionId, and value is a JSON scalar typed by
the question type (file answers carry a document id).
ClientInvoice (plan 18)
An agency→client invoice collected via Stripe Connect — funds settle to the agency's connected
account and Aurea takes an applicationFeeCents cut. Distinct from the platform PlatformInvoice
(agency→Aurea). All money is integer cents; render with formatPrice(cents / 100). Reads are
role-scoped: a client sees only invoices addressed to them, an agency_member sees all.
Prop
Type
InvoiceItem: id: ID!, description: String!, quantity: Float!, unitAmountCents: Int!,
amountCents: Int!, position: Int!.
RevenueSummary: paidCents: Int!, outstandingCents: Int!, overdueCents: Int! — the agency's
client-invoicing totals.
ConnectStatus: connected: Boolean!, chargesEnabled: Boolean! — Stripe Connect onboarding state
(the connected account id is never exposed).
ClientInvoiceStatus enum (plan 18)
DRAFT | SENT | PAID | PAYMENT_FAILED | VOID | OVERDUE
Queries
cases
List the cases visible to the caller. Reads are role-scoped server-side: a client sees only
their own cases (rows where clientId is their user id); an agency_member sees all cases.
query ListCases {
cases {
id
clientName
referenceCode
service
status
country
progress
updatedAt
}
}case(id: ID!)
Fetch a single case by ID, with the same role scoping - requesting another client's case id returns a not-found error (existence is not leaked).
query GetCase($id: ID!) {
case(id: $id) {
id
clientName
referenceCode
service
status
progress
statusHistory {
status
note
createdAt
}
}
}clients
List all CRM clients, most recently active first. Agency-only — a client-role token receives
FORBIDDEN.
query ListClients {
clients {
id
name
status
service
value
activeCases
owner {
id
name
}
updatedAt
}
}client(id: ID!)
Fetch a single CRM client (agency-only), typically with the cases resolve field for detail
views.
clientStats
The four KPI counts in one round-trip (agency-only):
query Stats {
clientStats {
total
active
newThisMonth
leads
}
}tasks(scope: TaskScope!, caseId: ID)
List agency tasks, soonest due first (no due date last). Agency-only — a client-role token
receives FORBIDDEN. Scoping is applied server-side: MY_TASKS filters to the signed-in
member's assignments, TEAM returns all. The optional caseId narrows to one case's tasks
(the CaseTracker Tasks tab).
query Tasks($scope: TaskScope!, $caseId: ID) {
tasks(scope: $scope, caseId: $caseId) {
id
title
status
priority
dueAt
internal
assignee {
id
name
}
case {
id
referenceCode
}
}
}taskStats
The four org-wide task KPI counts in one round-trip (agency-only): overdue, dueToday,
inProgress, completedThisWeek.
agencyMembers
Agency members assignable to tasks, alphabetically (agency-only). Powers the Add Task assignee picker.
leads
List all pipeline leads, newest first. Agency-only — a client-role token receives FORBIDDEN.
query Board {
leads {
id
clientName
value
priority
stageId
daysInStage
owner {
id
name
}
}
}pipelineTemplates
List all service pipeline templates with their nested stages, documents, and government
references (agency-only). Stages come ordered by position.
documents / document(id: ID!)
List the documents visible to the caller, or fetch one. Role-scoped server-side: a client sees
only their own rows; an agency_member sees all clients' rows. An out-of-scope document(id)
reads as not-found — existence is never confirmed across clients.
query MyDocuments {
documents {
id
name
status
fileName
note
}
}services(country: String, includeInactive: Boolean)
The Aurea master catalog. Any signed-in role; non-staff callers always read active services
(includeInactive is honored for super_admin only).
query Catalog {
services(country: "US") {
id
slug
title
category
questions {
id
label
type
}
documentRequirements {
id
name
classification
}
}
}service(idOrSlug: String!)
One catalog service by id or slug, with nested questions and document requirements. Staff resolve inactive services too; other roles get not-found.
agencyServiceConfigs
Every active catalog service with this agency's selection state merged (agency_member only).
agencyReports(range: ReportRange!): AgencyReports!
Bundled agency analytics for the selected time window. Agency members only -- a client token
receives FORBIDDEN. The result is cached in-process for 60 seconds per range key; rapid
successive calls with the same range return the cached aggregate.
query Reports($range: ReportRange!) {
agencyReports(range: $range) {
kpis {
label
value
trend
}
caseVolume {
label
value
}
casesByService {
label
value
}
funnel {
label
value
}
teamPerformance {
name
activeCases
closed
winRate
}
}
}range defaults to THIRTY_DAYS when omitted at the resolver level. Revenue and response-time
fields return zero/null stubs until plan-18 (Stripe Connect) and plan-12-followup land.
intake(caseId: ID!)
The intake for a case. A client reaches only their own case's intake (another client's case id
reads as not-found — existence is not leaked); agency_member and super_admin read any. The
intake is auto-created as a DRAFT on first read, so the wizard always has a stable response id.
query MyIntake($caseId: ID!) {
intake(caseId: $caseId) {
id
status
questions {
id
source
label
type
isRequired
}
documents {
id
name
classification
}
answers {
questionId
value
}
}
}Client invoicing queries (plan 18)
invoices: [ClientInvoice!]!— role-scoped: aclientsees only invoices addressed to them, anagency_membersees all.invoice(id: ID!): ClientInvoice!— re-checks the same scope; an out-of-scope id reads as not-found (never a leak).revenueSummary: RevenueSummary!— agency only; paid / outstanding / overdue cent totals.connectStatus: ConnectStatus!— agency only; Stripe Connect onboarding state.
Mutations
advanceCaseStatus(id: ID!)
Advance a case to its next lifecycle status following the progression:
INTAKE → DOCUMENTS_PENDING → SUBMITTED → IN_REVIEW → APPROVEDAPPROVED and REJECTED are terminal states — calling advanceCaseStatus on them is a no-op
(the status is returned unchanged).
Advancing also bumps progress, stamps submittedAt/decidedAt at their transitions, and
appends a CaseStatusEvent. Requires the agency_member role.
mutation AdvanceCase($id: ID!) {
advanceCaseStatus(id: $id) {
id
status
progress
}
}createCase(input: CreateCaseInput!)
Open a new case for a client (requires the agency_member role). The case starts at INTAKE
with an initial CaseStatusEvent.
mutation CreateCase($input: CreateCaseInput!) {
createCase(input: $input) {
id
referenceCode
status
}
}CreateCaseInput: clientId: ID!, ownerId: ID, referenceCode: String! (unique),
service: ImmigrationService!, country: String! (ISO-3166-1 alpha-2).
createClient(input: CreateClientInput!)
Add a CRM client (requires the agency_member role). status defaults to LEAD, value
to "0".
mutation AddClient($input: CreateClientInput!) {
createClient(input: $input) {
id
name
status
}
}CreateClientInput: name: String!, email: String!, company: String, phone: String,
service: String, status: ClientStatus, value: String (a non-negative numeric string —
money stays DECIMAL end-to-end; reads also return value as a String).
updateClient(id: ID!, input: UpdateClientInput!)
Patch a CRM client (agency-only). UpdateClientInput is CreateClientInput with every field
optional; omitted fields stay unchanged. Bumps updatedAt.
assignOwner(id: ID!, ownerId: ID!)
Reassign the agency member responsible for a client (agency-only). Unknown ownerId or client
id returns a not-found error.
createTask(input: CreateTaskInput!)
Add an agency task (agency-only). title and assigneeId are required; priority defaults to
MEDIUM, internal to false; dueAt, caseId, and clientId are optional. An unknown
assigneeId returns a not-found error.
mutation CreateTask($input: CreateTaskInput!) {
createTask(input: $input) {
id
title
status
assignee {
id
name
}
}
}updateTaskStatus(id: ID!, status: TaskStatus!)
Change a task's workflow status (mark done, board column move). Agency-only; bumps updatedAt.
assignTask(id: ID!, assigneeId: ID!)
Reassign the agency member responsible for a task (agency-only). An unknown assigneeId
returns a not-found error.
createLead(input: CreateLeadInput!)
Add a lead to the pipeline (agency-only). The lead starts in NEW_LEAD with priority
defaulting to MEDIUM and value to "0".
CreateLeadInput: clientName: String!, service: String!, email: String (optional at
capture but required before conversion), company: String, value: String (non-negative
numeric string), priority: LeadPriority.
moveLeadToStage(input: MoveLeadInput!)
Kanban drag-drop: move a lead to another stage. Resets stageEnteredAt, so daysInStage
restarts from the move. MoveLeadInput: leadId: ID!, stageId: PipelineStageId!.
mutation Move($input: MoveLeadInput!) {
moveLeadToStage(input: $input) {
id
stageId
daysInStage
}
}convertLeadToClient(id: ID!)
Convert a won lead into a CRM client (agency-only). Creates the Client through the same
service as createClient (with status: ACTIVE, carrying over name, email, company, service,
and value), links it back via convertedClientId, and lands the lead in WON.
The lead must have an email (clients.email is required) and must not already be converted —
both cases return a BAD_REQUEST error and create nothing.
savePipelineTemplate(input: PipelineTemplateInput!)
Create or update a pipeline template (builder save, agency-only). Omit input.id to create;
pass it to update. Nested stages, documents, and govReferences are diffed by id in one
transaction: entries with a known id are updated, entries without one inserted, and database
rows absent from the input deleted (a deleted stage cascades its documents). Stage position
is reassigned from the input array order on every save.
PipelineTemplateInput: id: ID, name: String!, route: String, description: String,
country: String, status: PipelineStatus (defaults to DRAFT), isCustomized: Boolean,
stages: [PipelineStageInput!]! (id: ID, title: String!, description: String,
documents: [PipelineDocumentInput!]!), govReferences: [GovReferenceInput!]! (id: ID,
authority: String!, title: String!, url: String!).
deletePipelineTemplate(id: ID!)
Delete a pipeline template (agency-only); its stages and documents cascade. Government
references survive with templateId set null — they are a shared registry the catalog's
legal document requirements may still reference. Returns the deleted template's ID.
syncGovReferences(templateId: ID) and acknowledgeGovReference(id: ID!)
Government-reference sync (plan 17, both agency-only). syncGovReferences runs an on-demand sync
(optionally scoped to one template) — per reference it does a conditional GET, hashes the normalized
body, and flips hasUpdates when a tracked source changed; it returns the affected
[GovReference!]!. A nightly cron runs the same sync automatically (GOV_SYNC_CRON, default
0 2 * * *; gated by GOV_SYNC_ENABLED). A detected change emits a gov_update notification to
agency members. acknowledgeGovReference clears hasUpdates for a reviewed reference and records
the actor/time, returning the updated GovReference. A fetch failure is a soft error
(lastSyncError) that never crashes the request or the cron.
Catalog curation (Aurea staff)
Every catalog mutation requires the super_admin role — an agency or client token is rejected:
upsertService(input: UpsertServiceInput!): Service!— create (noid) or update in place (withid). Omittingcontentpreserves the stored content; explicitnullclears it. Duplicate slugs are rejected with a conflict error.deleteService(id: ID!): ID!— cascades the service's questions and document requirements.upsertServiceQuestion(input: UpsertServiceQuestionInput!): ServiceQuestion!/deleteServiceQuestion(id: ID!): ID!upsertServiceQuestion/upsertServiceDocumentRequirementcarry aclassification(OFFICIAL | OFFICIAL_REQUIRED | NORMAL); the server coercesisRequiredfrom it.upsertServiceDocumentRequirement(input: UpsertServiceDocumentRequirementInput!): ServiceDocumentRequirement!/deleteServiceDocumentRequirement(id: ID!): ID!upsertServiceStage/deleteServiceStage— default stages (mandatory/order-locked enforced server-side).linkServiceGovReference/unlinkServiceGovReference— a service's authoritative sources.acknowledgeServiceReview(serviceId: ID!): Service!— clears the gov-driftneedsReviewflag.
Agency service configuration (agency_member)
setServiceOffered(serviceId: ID!, offered: Boolean!): AgencyServiceConfig!— enable/disable a catalog service for this agency.linkServicePipelineTemplate(serviceId: ID!, templateId: ID): AgencyServiceConfig!— link (or unlink withnull) the pipeline template the service feeds.upsertAgencyServiceQuestion(input: UpsertAgencyServiceQuestionInput!): AgencyServiceQuestion!/deleteAgencyServiceQuestion(id: ID!): ID!— agency custom intake questions.setServiceDocumentHidden(requirementId: ID!, hidden: Boolean!): AgencyServiceConfig!— hide or show an optional catalog document.
Official documents are locked server-side: setServiceDocumentHidden on any non-NORMAL
requirement is rejected with "Official documents are managed by Aurea and gov-synced" regardless
of what the UI allows.
Client intake
saveIntakeAnswers(responseId: ID!, answers: [IntakeAnswerInput!]!): Intake!— upsert questionnaire answers. Case-owning client only,DRAFTintakes only (a submitted intake is read-only); every answer must reference a question of the effective intake.submitIntake(responseId: ID!): Intake!— stampSUBMITTED+submittedAtand hand the intake to the agency. Case-owning client only; idempotent.
mutation Submit($responseId: ID!) {
submitIntake(responseId: $responseId) {
id
status
submittedAt
}
}Platform billing (agency_member)
The only Aurea-direct money path: the agency paying for the platform (plan 07). Every query and
mutation below requires the agency_member role — a client token is rejected with FORBIDDEN.
All prices and amounts are integer cents (Stripe minor units). Stripe secrets, customer ids
and price ids never cross the GraphQL surface.
Types — PlanCatalogItem (id, key, name, monthlyPrice: Int!, annualPrice: Int!
per-month when billed annually, baseSeats: Int!, isActive), PlatformSubscription (id,
planKey, frequency: PlatformBillingFrequency! of MONTHLY | ANNUAL,
status: PlatformSubscriptionStatus! mirroring Stripe subscription statuses,
currentPeriodEnd), PlatformInvoice (id, stripeInvoiceId, amount: Int!, currency,
status: PlatformInvoiceStatus! of PAID | OPEN | VOID | UNCOLLECTIBLE, issuedAt,
hostedInvoiceUrl), and PlatformBilling (subscription, plan, invoices).
planCatalog: [PlanCatalogItem!]!— active plan tiers + checkout addons for the picker.platformBilling: PlatformBilling!— the agency's current subscription, its plan, and the invoice history (newest first).createSubscriptionCheckoutSession(input: CreateSubscriptionCheckoutInput!): String!— start a Stripe subscription Checkout for a plan (+ optional addons); returns the redirect URL.CreateSubscriptionCheckoutInput:planKey: String!,frequency: PlatformBillingFrequency!,addons: [String!](catalog keys — prices always resolve server-side). Rate-limited; rejects with a conflict error while a subscription is alreadyACTIVE.createBillingPortalSession: String!— open the Stripe Customer Portal (payment methods, invoices, cancellation) scoped to the agency's Stripe customer; returns the redirect URL.
Subscription state is reconciled from the signature-verified /webhooks/stripe endpoint — the
Checkout success redirect is cosmetic. With STRIPE_SECRET_KEY unset the billing mutations return
a SERVICE_UNAVAILABLE-style error while the rest of the API keeps working.
Client invoicing mutations (plan 18)
All agency only. Money is integer cents, computed server-side.
createInvoice(input: CreateInvoiceInput!): ClientInvoice!— create aDRAFTinvoice (header + line items in one transaction).sendInvoice(id: ID!): ClientInvoice!— finalize + send via Stripe Connect on the agency's connected account, with anapplication_fee_amountcut for Aurea. Requires Connect onboarding to be complete, else a clear error.voidInvoice(id: ID!): ClientInvoice!— void in Stripe + locally; aPAIDinvoice cannot be voided.createConnectOnboardingLink: String!— start (or refresh) Stripe Connect Express onboarding; returns the onboarding URL.
Client-invoice payments are reconciled from the same signature-verified /webhooks/stripe route
as platform billing — the controller routes connected-account events (those carrying an account
field) to the invoicing handler. Status flips are idempotent, so a re-delivered invoice.paid
never double-counts revenue.
Onboarding State (plan 10)
Per-user onboarding progress stored as a JSONB blob on users. Every operation is
self-scoped - the API resolves the caller from @CurrentDbUser and will only read or write
the authenticated user's own row. There is no userId argument: passing one would be a no-op
(the server ignores it and uses the session identity).
Type - OnboardingState:
| Field | Type | Description |
|---|---|---|
currentStep | Int! | 1-based step the user is on (1-5) |
steps | JSON! | Step payloads keyed by step id (profile, services, team, comms, firstClient) |
completedAt | DateTime | When completeOnboarding was last called; null while in progress |
isComplete | Boolean! | Derived from completedAt != null |
onboardingState: OnboardingState!- read the authenticated user's current progress.updateOnboardingStep(input: UpdateOnboardingStepInput!): OnboardingState!- merge a step payload and advancecurrentStep. Input:stepId: String!(must be one of the five known step ids - unknown ids are rejected withBAD_USER_INPUT),payload: JSON!(validated against the per-step@repo/sharedzod schema; oversized or invalid payloads are rejected),currentStep: Int!(new step index). Idempotent on the same step.completeOnboarding: OnboardingState!- stampcompletedAt = now(). Idempotent; calling it again when already complete is a no-op that returns the existing state.
Agency members who have not yet called completeOnboarding are routed to /onboarding by the
RoleRedirect component. Direct navigation to /onboarding after completion is intercepted by
RequireIncompleteOnboarding, which redirects to the role landing without rendering the wizard.
Realtime Messaging (plan 08)
Two-way messaging between clients and agency members, backed by Postgres and delivered live over
graphql-ws. Scope is enforced server-side: clients see only their own threads.
Conversation type:
| Field | Type | Description |
|---|---|---|
id | ID! | UUID |
clientId | ID! | FK to users.id (portal login, NOT clients.id) |
caseId | ID | Optional FK to a case |
subject | String | Thread subject |
lastMessageAt | DateTime | For list ordering |
unreadCount | Int! | Messages not sent by caller with readAt = null |
messages | [Message!]! | Resolved field - oldest first |
Message type:
| Field | Type | Description |
|---|---|---|
id | ID! | UUID |
conversationId | ID! | Parent conversation |
senderId | ID | Null for system/deactivated-member messages |
body | String! | Message text |
readAt | DateTime | Null until marked read by the receiver |
createdAt | DateTime! |
Queries and mutations:
conversations: [Conversation!]!- role-scoped list (clients: own threads only; agency: all).conversation(id: ID!): Conversation!- single thread; throws not-found for out-of-scope ids.sendMessage(input: SendMessageInput!): Message!- input:{ conversationId: ID!, body: String! }.senderIdis set server-side; it cannot be supplied by the caller.markConversationRead(conversationId: ID!): Boolean!- stampsreadAton messages not sent by the caller (including system messages withsenderId = null).getOrCreateConversation(clientUserId: ID!): Conversation!- agency only; idempotent per client user. Argument isusers.id, notclients.id.
Live subscription:
subscription MessageAdded($conversationId: ID!) {
messageAdded(conversationId: $conversationId) {
id
conversationId
senderId
body
readAt
createdAt
}
}The filter enforces two gates: (1) conversationId must match, and (2) the subscriber must have
scope access to that conversation. An authenticated-but-out-of-scope subscriber receives no
events (security: TS-004).
Public Marketing Lead Mutation (plan 13)
submitMarketingLead is the only unauthenticated write in the API. No Authorization header
is required -- it is intentionally public so the marketing site (apps/web /contact) can submit
consultation requests without a Clerk session.
mutation SubmitMarketingLead($input: SubmitMarketingLeadInput!) {
submitMarketingLead(input: $input) {
id
}
}Input fields:
| Field | Type | Required | Notes |
|---|---|---|---|
name | String | Yes | Full name, max 100 chars |
email | String | Yes | Valid email -- required to reach the visitor |
company | String | No | Max 100 chars |
service | String | No | Service interest, max 200 chars |
turnstileToken | String | No | Cloudflare Turnstile token; verified only when TURNSTILE_SECRET_KEY is configured |
Rate limit: 5 requests / minute / IP (tighter than the global 100/min for unauthenticated writes). Returns HTTP 429 / GraphQL throttle error when exceeded.
Abuse protection: IP rate-limit is always active. Cloudflare Turnstile bot-protection is
optional -- configure TURNSTILE_SECRET_KEY (API) + NEXT_PUBLIC_TURNSTILE_SITE_KEY (web)
before any public production deploy. Without Turnstile the only abuse defence is the rate limit.
Successful submissions land in the agency pipeline at the new_lead stage and are visible in
/agency/app/pipeline immediately.
Notifications & Comms (plan 15)
In-app notifications written by the server-side notify event bus (domains emit, a single
listener persists + publishes) and delivered live over graphql-ws. Every operation is
self-scoped: a caller only ever reads or mutates notifications addressed to them - a foreign
id reads as not-found, never as forbidden (no existence leak).
Notification type:
| Field | Type | Description |
|---|---|---|
id | ID! | UUID |
type | NotificationType! | One of case_update, document_feedback, task_assigned, task_due, new_message, new_lead, ai_review_ready |
title | String! | Short headline |
body | String | Optional detail line |
entityType | String | Deep-link target kind (case, document, conversation, advisor_conversation, task, lead) |
entityId | ID | Deep-link target id |
readAt | DateTime | Null = unread |
createdAt | DateTime! |
Queries and mutations:
notifications(unreadOnly: Boolean): [Notification!]!- the caller's feed, newest first.notification(id: ID!): Notification!- one of the caller's notifications.unreadNotificationsCount: Int!- badge count (readAt IS NULL).markNotificationRead(id: ID!): Notification!- stampsreadAton the caller's row.markAllNotificationsRead: Boolean!- stamps every unread row of the caller.
Live subscription:
subscription NotificationAdded {
notificationAdded {
id
type
title
body
entityType
entityId
readAt
createdAt
}
}The filter matches the recipient's user id - a socket only receives notifications addressed to its own authenticated user (security: TS-004).
Out-of-app delivery: the same events fan out to email (Resend) and a WhatsApp stub via the
API's CommsService, gated by the per-user Settings notification preferences. Provider keys are
optional - with RESEND_API_KEY / RESEND_FROM_EMAIL (and WHATSAPP_API_KEY / WHATSAPP_FROM)
unset, sends are logged no-ops and in-app notifications keep working. Successful WhatsApp sends
are metered as plan-14 comms usage.
AI Document Review (plan 16)
When a client uploads a document, the API asks Claude to review it against the slot it is supposed to
fill and stores a structured verdict. The verdict is advisory: a flag never changes the
document's human status on its own, and review runs asynchronously - the upload never waits on, or
fails because of, the model.
DocumentReview type (exposed as the nullable review field on Document):
| Field | Type | Description |
|---|---|---|
state | DocumentReviewState! | pending, complete, failed, or skipped |
verdict | DocumentReviewVerdict | pass or flag; null until state is complete |
message | String | Short, specific note (e.g. "missing the final page (page 3 of 3)") |
model | String | Model that produced the verdict (provenance), e.g. claude-sonnet-4-6 |
createdAt | DateTime! | |
updatedAt | DateTime! |
The review field is pre-joined onto the role-scoped documents query, so a client only ever reads
verdicts for their own documents (a foreign id reads as not-found, never forbidden).
query AgencyDocuments {
documents {
id
name
status
review {
state
verdict
message
}
}
}Mutation: rerunDocumentReview(documentId: ID!): Document! - re-runs the review and returns the
document with the fresh verdict. Agency members only; clients are read-only.
A skipped/failed review (oversized/unsupported file, or the model unavailable) leaves the document
fully usable; the raw provider error and the ANTHROPIC_API_KEY are never returned in the response.
Example with curl
curl -X POST http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <clerk-session-token>' \
-d '{"query":"{ cases { id clientName status } }"}'