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:
serfowi
2026-08-20 13:32:35 -04:00
co-authored by Claude Opus 5
commit 19623bcccb
66 changed files with 12412 additions and 0 deletions
+135
View File
@@ -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;
}