/** * Every status transition in the product lives here as a pure function. * tRPC mutations MUST route through `assertTransition` — nothing gets to jump * from `scheduled` straight to `completed` because a client sent a crafted payload. */ export class TransitionError extends Error { constructor( readonly entity: string, readonly from: string, readonly to: string, ) { super(`Illegal ${entity} transition: ${from} -> ${to}`); this.name = 'TransitionError'; } } type Graph = Readonly>; export const JOB_STATUSES = ['open', 'matched', 'booked', 'completed', 'cancelled'] as const; export type JobStatus = (typeof JOB_STATUSES)[number]; /** * The live half of the job lifecycle. * * "Is this job happening or is it history?" is asked by the jobs list, the * counts on it and the chat composer, so the answer is defined once here rather * than as three copies of the same status array. */ export const ACTIVE_JOB_STATUSES: readonly JobStatus[] = ['open', 'matched', 'booked']; export const PAST_JOB_STATUSES: readonly JobStatus[] = ['completed', 'cancelled']; /** * How a stored coordinate was obtained. * * Not a status — nothing transitions between these — but it lives here with the * other unions because the DB enum is generated from it (schema/enums.ts) and a * value that exists in one place and not the other is the bug this file exists * to prevent. * * `exact` a geocoded street address. Safe to rank on. * `approximate` a street, postcode or device GPS fix. Real, but not a rooftop. * `city` nothing resolved; the point is the city centre. A placeholder, * and labelled as one so no surface can mistake it for a location. */ export const LOCATION_PRECISIONS = ['exact', 'approximate', 'city'] as const; export type LocationPrecision = (typeof LOCATION_PRECISIONS)[number]; /** Points good enough to measure distance from. */ export const LOCATABLE_PRECISIONS: readonly LocationPrecision[] = ['exact', 'approximate']; export const REQUEST_STATUSES = ['pending', 'accepted', 'declined', 'expired'] as const; export type RequestStatus = (typeof REQUEST_STATUSES)[number]; export const QUOTE_STATUSES = ['sent', 'accepted', 'declined', 'withdrawn', 'expired'] as const; export type QuoteStatus = (typeof QUOTE_STATUSES)[number]; export const BOOKING_STATUSES = [ 'scheduled', 'in_progress', 'awaiting_confirmation', 'completed', 'cancelled', 'disputed', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; export const PAYMENT_STATUSES = [ 'pending', 'held', 'released', 'refunded', 'partially_refunded', 'failed', ] as const; export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; export const VERIFICATION_STATUSES = [ 'draft', 'pending', 'verified', 'rejected', 'suspended', ] as const; export type VerificationStatus = (typeof VERIFICATION_STATUSES)[number]; const JOB_GRAPH: Graph = { open: ['matched', 'cancelled'], matched: ['booked', 'open', 'cancelled'], // back to `open` when every match falls through // 'matched' because a cancelled booking is not a cancelled job: the customer // still wants the work and their other conversations are untouched, so the job // goes back to the market rather than dying with the slot. Same spirit as // 'matched -> open' above. booked: ['completed', 'cancelled', 'matched'], completed: [], cancelled: [], }; const REQUEST_GRAPH: Graph = { pending: ['accepted', 'declined', 'expired'], accepted: [], declined: [], expired: [], }; const QUOTE_GRAPH: Graph = { sent: ['accepted', 'declined', 'withdrawn', 'expired'], accepted: [], declined: [], withdrawn: [], expired: [], }; const BOOKING_GRAPH: Graph = { // 'awaiting_confirmation' directly, because `in_progress` is optional. // Marking a job started is useful to a customer waiting for someone to turn // up, but a twenty-minute job finished by a pro who never tapped Start is the // common case, and being unable to say it is done would be absurd. scheduled: ['in_progress', 'awaiting_confirmation', 'cancelled'], in_progress: ['awaiting_confirmation', 'cancelled', 'disputed'], awaiting_confirmation: ['completed', 'disputed'], completed: ['disputed'], // a dispute can still be raised inside the window cancelled: [], disputed: ['completed', 'cancelled'], }; const PAYMENT_GRAPH: Graph = { pending: ['held', 'failed'], held: ['released', 'refunded', 'partially_refunded'], released: ['refunded', 'partially_refunded'], refunded: [], partially_refunded: ['refunded'], failed: ['pending'], }; const VERIFICATION_GRAPH: Graph = { draft: ['pending'], pending: ['verified', 'rejected'], // 'pending' is reachable from 'verified' because a material profile edit // (trade, base location, service radius) sends a live pro back for re-review. // Without this edge the re-review in pro.upsertProfile is not representable // and a verified plumber could silently become a verified electrician. verified: ['suspended', 'pending'], rejected: ['pending'], suspended: ['verified', 'rejected'], }; const GRAPHS = { job: JOB_GRAPH, request: REQUEST_GRAPH, quote: QUOTE_GRAPH, booking: BOOKING_GRAPH, payment: PAYMENT_GRAPH, verification: VERIFICATION_GRAPH, } as const; export type Entity = keyof typeof GRAPHS; export function canTransition(entity: Entity, from: string, to: string): boolean { const graph = GRAPHS[entity] as Graph; const allowed = graph[from]; return allowed !== undefined && allowed.includes(to); } export function assertTransition(entity: Entity, from: string, to: string): void { if (!canTransition(entity, from, to)) throw new TransitionError(entity, from, to); } export function nextStates(entity: Entity, from: string): readonly string[] { const graph = GRAPHS[entity] as Graph; return graph[from] ?? []; } export function isTerminal(entity: Entity, state: string): boolean { return nextStates(entity, state).length === 0; }