Aurea Docs
API Reference

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/graphql

The 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

ValueDescription
LEADProspect — no engagement yet
ACTIVEEngaged client with ongoing work
ON_HOLDEngagement paused
CLOSEDRelationship concluded

TaskStatus enum

TODO | IN_PROGRESS | BLOCKED | DONE

TaskPriority enum

LOW | MEDIUM | HIGH

TaskScope enum

ValueDescription
MY_TASKSOnly tasks assigned to the signed-in member
TEAMAll agency tasks

CaseStatus enum

ValueDescription
INTAKEInitial intake — case opened
DOCUMENTS_PENDINGWaiting for client documents
SUBMITTEDDocuments submitted to authority
IN_REVIEWUnder review by authority
APPROVEDCase approved
REJECTEDCase rejected

ImmigrationService enum

ValueLabel
US_VISAUS Visa Application
DIGITAL_NOMAD_VISADigital Nomad Visa
CITIZENSHIP_APPLICATIONCitizenship Application
SPANISH_DRIVERS_LICENSESpanish Driver's License Exchange

ReportRange enum

Time window for the agencyReports query.

ValueDescription
SEVEN_DAYSRolling 7 days ending now
THIRTY_DAYSRolling 30 days ending now (default)
QTDQuarter-to-date (first day of the current calendar quarter)
YTDYear-to-date (January 1 of the current calendar year)

DocumentStatus enum

ValueDescription
requestedAgency asked for it — no file yet
uploadedBytes confirmed in storage (HeadObject-verified)
in_reviewAgency is reviewing
approvedAccepted
rejectedSent 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).

FieldTypeDescription
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: a client sees only invoices addressed to them, an agency_member sees 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 → APPROVED

APPROVED 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 (no id) or update in place (with id). Omitting content preserves the stored content; explicit null clears 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 / upsertServiceDocumentRequirement carry a classification (OFFICIAL | OFFICIAL_REQUIRED | NORMAL); the server coerces isRequired from 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-drift needsReview flag.

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 with null) 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, DRAFT intakes only (a submitted intake is read-only); every answer must reference a question of the effective intake.
  • submitIntake(responseId: ID!): Intake! — stamp SUBMITTED + submittedAt and 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.

TypesPlanCatalogItem (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 already ACTIVE.
  • 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 a DRAFT invoice (header + line items in one transaction).
  • sendInvoice(id: ID!): ClientInvoice! — finalize + send via Stripe Connect on the agency's connected account, with an application_fee_amount cut for Aurea. Requires Connect onboarding to be complete, else a clear error.
  • voidInvoice(id: ID!): ClientInvoice! — void in Stripe + locally; a PAID invoice 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:

FieldTypeDescription
currentStepInt!1-based step the user is on (1-5)
stepsJSON!Step payloads keyed by step id (profile, services, team, comms, firstClient)
completedAtDateTimeWhen completeOnboarding was last called; null while in progress
isCompleteBoolean!Derived from completedAt != null
  • onboardingState: OnboardingState! - read the authenticated user's current progress.
  • updateOnboardingStep(input: UpdateOnboardingStepInput!): OnboardingState! - merge a step payload and advance currentStep. Input: stepId: String! (must be one of the five known step ids - unknown ids are rejected with BAD_USER_INPUT), payload: JSON! (validated against the per-step @repo/shared zod schema; oversized or invalid payloads are rejected), currentStep: Int! (new step index). Idempotent on the same step.
  • completeOnboarding: OnboardingState! - stamp completedAt = 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:

FieldTypeDescription
idID!UUID
clientIdID!FK to users.id (portal login, NOT clients.id)
caseIdIDOptional FK to a case
subjectStringThread subject
lastMessageAtDateTimeFor list ordering
unreadCountInt!Messages not sent by caller with readAt = null
messages[Message!]!Resolved field - oldest first

Message type:

FieldTypeDescription
idID!UUID
conversationIdID!Parent conversation
senderIdIDNull for system/deactivated-member messages
bodyString!Message text
readAtDateTimeNull until marked read by the receiver
createdAtDateTime!

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! }. senderId is set server-side; it cannot be supplied by the caller.
  • markConversationRead(conversationId: ID!): Boolean! - stamps readAt on messages not sent by the caller (including system messages with senderId = null).
  • getOrCreateConversation(clientUserId: ID!): Conversation! - agency only; idempotent per client user. Argument is users.id, not clients.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:

FieldTypeRequiredNotes
nameStringYesFull name, max 100 chars
emailStringYesValid email -- required to reach the visitor
companyStringNoMax 100 chars
serviceStringNoService interest, max 200 chars
turnstileTokenStringNoCloudflare 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:

FieldTypeDescription
idID!UUID
typeNotificationType!One of case_update, document_feedback, task_assigned, task_due, new_message, new_lead, ai_review_ready
titleString!Short headline
bodyStringOptional detail line
entityTypeStringDeep-link target kind (case, document, conversation, advisor_conversation, task, lead)
entityIdIDDeep-link target id
readAtDateTimeNull = unread
createdAtDateTime!

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! - stamps readAt on 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):

FieldTypeDescription
stateDocumentReviewState!pending, complete, failed, or skipped
verdictDocumentReviewVerdictpass or flag; null until state is complete
messageStringShort, specific note (e.g. "missing the final page (page 3 of 3)")
modelStringModel that produced the verdict (provenance), e.g. claude-sonnet-4-6
createdAtDateTime!
updatedAtDateTime!

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 } }"}'

On this page

EndpointAuthenticationSubscriptionsSchemaTypesCaseCaseStatusEventClientClientStatsTaskTaskStatsLeadPipelineTemplateDocumentLeadPriority enumPipelineStageId enumPipelineStatus enumClientStatus enumTaskStatus enumTaskPriority enumTaskScope enumCaseStatus enumImmigrationService enumReportRange enumDocumentStatus enumServiceServiceQuestionServiceDocumentRequirementAgencyReportsAgencyServiceConfigIntakeClientInvoice (plan 18)ClientInvoiceStatus enum (plan 18)Queriescasescase(id: ID!)clientsclient(id: ID!)clientStatstasks(scope: TaskScope!, caseId: ID)taskStatsagencyMembersleadspipelineTemplatesdocuments / document(id: ID!)services(country: String, includeInactive: Boolean)service(idOrSlug: String!)agencyServiceConfigsagencyReports(range: ReportRange!): AgencyReports!intake(caseId: ID!)Client invoicing queries (plan 18)MutationsadvanceCaseStatus(id: ID!)createCase(input: CreateCaseInput!)createClient(input: CreateClientInput!)updateClient(id: ID!, input: UpdateClientInput!)assignOwner(id: ID!, ownerId: ID!)createTask(input: CreateTaskInput!)updateTaskStatus(id: ID!, status: TaskStatus!)assignTask(id: ID!, assigneeId: ID!)createLead(input: CreateLeadInput!)moveLeadToStage(input: MoveLeadInput!)convertLeadToClient(id: ID!)savePipelineTemplate(input: PipelineTemplateInput!)deletePipelineTemplate(id: ID!)syncGovReferences(templateId: ID) and acknowledgeGovReference(id: ID!)Catalog curation (Aurea staff)Agency service configuration (agency_member)Client intakePlatform billing (agency_member)Client invoicing mutations (plan 18)Onboarding State (plan 10)Realtime Messaging (plan 08)Public Marketing Lead Mutation (plan 13)Notifications & Comms (plan 15)AI Document Review (plan 16)Example with curl