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
+54
View File
@@ -0,0 +1,54 @@
import { FREE_CANCELLATION_HOURS, LATE_CANCELLATION_FEE_BPS } from './constants';
import type { Cents } from './money';
import { platformFee } from './money';
export type CancelledBy = 'client' | 'pro' | 'admin';
export interface CancellationOutcome {
/** Returned to the client. */
refundCents: Cents;
/** Kept by the platform and/or paid to the pro as a late-cancellation fee. */
feeCents: Cents;
reason: string;
}
/**
* Who eats the cost of a cancellation.
*
* A pro cancelling is always a full refund — they took the slot and dropped it,
* that is not the client's problem. A client cancelling inside the window pays a
* fee, because the pro has already turned down other work for that slot.
*/
export function cancellationOutcome(args: {
amountCents: Cents;
scheduledStart: Date;
cancelledBy: CancelledBy;
now?: Date;
}): CancellationOutcome {
const { amountCents, scheduledStart, cancelledBy } = args;
const now = args.now ?? new Date();
if (cancelledBy !== 'client') {
return {
refundCents: amountCents,
feeCents: 0,
reason: `Cancelled by ${cancelledBy} — full refund`,
};
}
const hoursUntil = (scheduledStart.getTime() - now.getTime()) / 3_600_000;
if (hoursUntil >= FREE_CANCELLATION_HOURS) {
return {
refundCents: amountCents,
feeCents: 0,
reason: `Cancelled ${Math.floor(hoursUntil)}h ahead — free cancellation`,
};
}
const feeCents = platformFee(amountCents, LATE_CANCELLATION_FEE_BPS);
return {
refundCents: amountCents - feeCents,
feeCents,
reason: `Cancelled inside the ${FREE_CANCELLATION_HOURS}h window — late cancellation fee applies`,
};
}
+35
View File
@@ -0,0 +1,35 @@
/** Product rules that get tuned. Keep them in one place — you will change these weekly. */
/** Max open (pending) requests a client can have out on a single job. Stops city-spraying. */
export const MAX_OPEN_REQUESTS_PER_JOB = 5;
/** How long a pro has to respond before a request auto-expires. */
export const REQUEST_TTL_HOURS = {
now: 12,
this_week: 48,
flexible: 48,
} as const;
/** Client has this long to confirm completion before it auto-confirms and pays out. */
export const AUTO_CONFIRM_HOURS = 72;
/** A quote is only good for this long. */
export const QUOTE_VALIDITY_HOURS = 72;
/** Cards prefetched per deck page. */
export const DECK_PAGE_SIZE = 20;
/** Free cancellation window before the booked slot. Inside it, a fee applies. */
export const FREE_CANCELLATION_HOURS = 24;
export const LATE_CANCELLATION_FEE_BPS = 2500; // 25% of the quote
/** Default platform commission. Overridden by PLATFORM_FEE_BPS env at runtime. */
export const DEFAULT_PLATFORM_FEE_BPS = 1500; // 15%
export const MIN_QUOTE_CENTS = 500; // €5 — below this, escrow overhead isn't worth it
export const MAX_QUOTE_CENTS = 2_000_000; // €20,000 sanity ceiling
/** Pro service radius bounds, metres. */
export const MIN_SERVICE_RADIUS_M = 1_000;
export const MAX_SERVICE_RADIUS_M = 50_000;
export const DEFAULT_SERVICE_RADIUS_M = 15_000;
+6
View File
@@ -0,0 +1,6 @@
export * from './constants';
export * from './money';
export * from './state-machines';
export * from './ranking';
export * from './cancellation';
export * from './schemas';
+51
View File
@@ -0,0 +1,51 @@
/**
* Money is always integer minor units (cents). Never floats — 0.1 + 0.2 problems
* become customer support tickets when they happen to someone's payout.
*/
export type Cents = number;
export class MoneyError extends Error {}
export function assertCents(value: number, label = 'amount'): asserts value is Cents {
if (!Number.isInteger(value)) throw new MoneyError(`${label} must be an integer, got ${value}`);
if (value < 0) throw new MoneyError(`${label} must not be negative, got ${value}`);
if (!Number.isSafeInteger(value)) throw new MoneyError(`${label} exceeds safe integer range`);
}
/** Basis points: 1500 bps = 15%. */
export type Bps = number;
/**
* Platform commission. Rounds half-up so the platform never takes a fraction of a
* cent more than stated, and the pro's share absorbs the remainder.
*/
export function platformFee(amount: Cents, feeBps: Bps): Cents {
assertCents(amount);
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10_000) {
throw new MoneyError(`feeBps must be an integer in [0, 10000], got ${feeBps}`);
}
return Math.round((amount * feeBps) / 10_000);
}
/** What actually lands in the pro's connected account. */
export function proPayout(amount: Cents, feeBps: Bps): Cents {
return amount - platformFee(amount, feeBps);
}
/** Split a charge into its parts. The two always sum back to `amount`. */
export function splitCharge(amount: Cents, feeBps: Bps): { fee: Cents; payout: Cents } {
const fee = platformFee(amount, feeBps);
return { fee, payout: amount - fee };
}
export function formatCents(amount: Cents, currency = 'EUR', locale = 'en-IE'): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
}
export function parseAmountToCents(input: string): Cents {
const normalised = input.replace(/[^0-9.,-]/g, '').replace(',', '.');
const parsed = Number.parseFloat(normalised);
if (Number.isNaN(parsed)) throw new MoneyError(`Cannot parse "${input}" as an amount`);
return Math.round(parsed * 100);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Deck ranking. Every pro shown to a client is scored here.
*
* These weights are the product. Expect to tune them weekly against booking
* conversion — that is why they are one exported object and not sprinkled
* through a SQL string.
*/
export const RANKING_WEIGHTS = {
rating: 0.35,
responseRate: 0.25,
proximity: 0.2,
recency: 0.1,
newProBoost: 0.1,
} as const;
/** A pro with no reviews yet is treated as this rating, so they aren't buried at 0. */
export const UNRATED_BASELINE = 4.0;
/** Reviews needed before a pro's real rating fully replaces the baseline. */
export const RATING_CONFIDENCE_N = 5;
/** New pros get a decaying boost for this long so fresh supply gets seen. */
export const NEW_PRO_GRACE_DAYS = 30;
export interface RankingInput {
ratingAvg: number | null;
ratingCount: number;
/** 0..1 — accepted or declined within TTL, vs let expire. */
responseRate: number | null;
distanceM: number;
serviceRadiusM: number;
lastActiveAt: Date | null;
createdAt: Date;
now?: Date;
}
/**
* Bayesian-smoothed rating: pulls a 5.0-from-one-review pro back toward the
* baseline until they have a real track record. Without this, the deck is topped
* by whoever got a single review from a friend.
*/
export function smoothedRating(ratingAvg: number | null, ratingCount: number): number {
if (ratingAvg === null || ratingCount === 0) return UNRATED_BASELINE;
const n = RATING_CONFIDENCE_N;
return (ratingAvg * ratingCount + UNRATED_BASELINE * n) / (ratingCount + n);
}
/** Linear falloff across the pro's own radius — near the edge is worth less than next door. */
export function proximityScore(distanceM: number, serviceRadiusM: number): number {
if (serviceRadiusM <= 0) return 0;
return clamp01(1 - distanceM / serviceRadiusM);
}
/** Active today = 1, decaying to 0 over two weeks. Dormant pros don't respond. */
export function recencyScore(lastActiveAt: Date | null, now: Date): number {
if (!lastActiveAt) return 0;
const days = (now.getTime() - lastActiveAt.getTime()) / 86_400_000;
return clamp01(1 - days / 14);
}
/** Decaying head start for pros in their first month. */
export function newProBoost(createdAt: Date, now: Date): number {
const days = (now.getTime() - createdAt.getTime()) / 86_400_000;
return clamp01(1 - days / NEW_PRO_GRACE_DAYS);
}
/** Final deck score, 0..1. Higher sorts first. */
export function score(input: RankingInput): number {
const now = input.now ?? new Date();
const w = RANKING_WEIGHTS;
return (
w.rating * (smoothedRating(input.ratingAvg, input.ratingCount) / 5) +
w.responseRate * clamp01(input.responseRate ?? 0.5) +
w.proximity * proximityScore(input.distanceM, input.serviceRadiusM) +
w.recency * recencyScore(input.lastActiveAt, now) +
w.newProBoost * newProBoost(input.createdAt, now)
);
}
function clamp01(n: number): number {
if (Number.isNaN(n)) return 0;
return Math.min(1, Math.max(0, n));
}
+116
View File
@@ -0,0 +1,116 @@
import { z } from 'zod';
import {
MAX_QUOTE_CENTS,
MAX_SERVICE_RADIUS_M,
MIN_QUOTE_CENTS,
MIN_SERVICE_RADIUS_M,
} from './constants';
export const urgencySchema = z.enum(['now', 'this_week', 'flexible']);
export type Urgency = z.infer<typeof urgencySchema>;
export const roleSchema = z.enum(['client', 'pro', 'admin']);
export type Role = z.infer<typeof roleSchema>;
export const swipeDirectionSchema = z.enum(['left', 'right']);
export type SwipeDirection = z.infer<typeof swipeDirectionSchema>;
export const latLngSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
});
export type LatLng = z.infer<typeof latLngSchema>;
export const centsSchema = z.number().int().nonnegative();
export const createJobSchema = z
.object({
categoryId: z.string().uuid(),
title: z.string().min(5).max(120),
description: z.string().min(20).max(4000),
photos: z.array(z.string().url()).max(8).default([]),
urgency: urgencySchema,
budgetMinCents: centsSchema.optional(),
budgetMaxCents: centsSchema.optional(),
location: latLngSchema,
addressText: z.string().min(3).max(255),
})
.refine(
(v) =>
v.budgetMinCents === undefined ||
v.budgetMaxCents === undefined ||
v.budgetMinCents <= v.budgetMaxCents,
{ message: 'Minimum budget cannot exceed maximum', path: ['budgetMinCents'] },
);
export type CreateJobInput = z.infer<typeof createJobSchema>;
export const proProfileSchema = z.object({
headline: z.string().min(5).max(100),
bio: z.string().min(30).max(2000),
hourlyRateCents: centsSchema.max(MAX_QUOTE_CENTS),
yearsExperience: z.number().int().min(0).max(70),
categoryIds: z.array(z.string().uuid()).min(1, 'Pick at least one trade').max(5),
location: latLngSchema,
serviceRadiusM: z.number().int().min(MIN_SERVICE_RADIUS_M).max(MAX_SERVICE_RADIUS_M),
});
export type ProProfileInput = z.infer<typeof proProfileSchema>;
export const swipeSchema = z.object({
jobId: z.string().uuid(),
proId: z.string().uuid(),
direction: swipeDirectionSchema,
});
export type SwipeInput = z.infer<typeof swipeSchema>;
export const createQuoteSchema = z
.object({
matchId: z.string().uuid(),
kind: z.enum(['fixed', 'hourly']),
amountCents: centsSchema.min(MIN_QUOTE_CENTS).max(MAX_QUOTE_CENTS),
hoursEstimate: z.number().positive().max(1000).optional(),
scope: z.string().min(10).max(2000),
})
.refine((v) => v.kind !== 'hourly' || v.hoursEstimate !== undefined, {
message: 'An hourly quote needs an hours estimate',
path: ['hoursEstimate'],
});
export type CreateQuoteInput = z.infer<typeof createQuoteSchema>;
export const createBookingSchema = z
.object({
matchId: z.string().uuid(),
quoteId: z.string().uuid(),
scheduledStart: z.coerce.date(),
scheduledEnd: z.coerce.date(),
})
.refine((v) => v.scheduledEnd > v.scheduledStart, {
message: 'End must be after start',
path: ['scheduledEnd'],
})
.refine((v) => v.scheduledStart.getTime() > Date.now() - 60_000, {
message: 'Cannot book a slot in the past',
path: ['scheduledStart'],
});
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
export const createReviewSchema = z.object({
bookingId: z.string().uuid(),
rating: z.number().int().min(1).max(5),
body: z.string().min(10).max(1500),
});
export type CreateReviewInput = z.infer<typeof createReviewSchema>;
export const sendMessageSchema = z.object({
matchId: z.string().uuid(),
body: z.string().min(1).max(4000),
attachments: z.array(z.string().url()).max(5).default([]),
});
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
export const credentialSchema = z.object({
kind: z.enum(['id', 'licence', 'insurance']),
fileUrl: z.string().url(),
issuer: z.string().max(120).optional(),
expiresAt: z.coerce.date().optional(),
});
export type CredentialInput = z.infer<typeof credentialSchema>;
+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;
}