M0: foundation — monorepo, PostGIS schema, deck query, app shell
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.
- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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<T extends string> = Readonly<Record<T, readonly T[]>>;
|
||||
|
||||
export const JOB_STATUSES = ['open', 'matched', 'booked', 'completed', 'cancelled'] as const;
|
||||
export type JobStatus = (typeof JOB_STATUSES)[number];
|
||||
|
||||
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<JobStatus> = {
|
||||
open: ['matched', 'cancelled'],
|
||||
matched: ['booked', 'open', 'cancelled'], // back to `open` when every match falls through
|
||||
booked: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
const REQUEST_GRAPH: Graph<RequestStatus> = {
|
||||
pending: ['accepted', 'declined', 'expired'],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
expired: [],
|
||||
};
|
||||
|
||||
const QUOTE_GRAPH: Graph<QuoteStatus> = {
|
||||
sent: ['accepted', 'declined', 'withdrawn', 'expired'],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
withdrawn: [],
|
||||
expired: [],
|
||||
};
|
||||
|
||||
const BOOKING_GRAPH: Graph<BookingStatus> = {
|
||||
scheduled: ['in_progress', '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<PaymentStatus> = {
|
||||
pending: ['held', 'failed'],
|
||||
held: ['released', 'refunded', 'partially_refunded'],
|
||||
released: ['refunded', 'partially_refunded'],
|
||||
refunded: [],
|
||||
partially_refunded: ['refunded'],
|
||||
failed: ['pending'],
|
||||
};
|
||||
|
||||
const VERIFICATION_GRAPH: Graph<VerificationStatus> = {
|
||||
draft: ['pending'],
|
||||
pending: ['verified', 'rejected'],
|
||||
verified: ['suspended'],
|
||||
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<string>;
|
||||
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<string>;
|
||||
return graph[from] ?? [];
|
||||
}
|
||||
|
||||
export function isTerminal(entity: Entity, state: string): boolean {
|
||||
return nextStates(entity, state).length === 0;
|
||||
}
|
||||
Reference in New Issue
Block a user