M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews

Closes the funnel. Before this the product could match two people and then
stopped: `quotes`, `bookings` and `reviews` had tables and state machines and
nothing that wrote a row, the entry deck's right swipe was wired to an empty
handler, and every address resolved to the city centre.

Jobs tab and chat
- message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not
  a job — one job with three interested pros is three private conversations.
- Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the
  pros who accepted, and the conversation itself with attachments.

Hiring from the deck
- A right swipe on the entry deck opened nothing. It now resolves "which job?"
  through a sheet — sign in, pick an open job, or post one — and calls the same
  deck.swipe the per-job deck does, so the open-request cap and row lock apply
  exactly once. Swipes are vetoable so closing the sheet returns the card.

Geocoding
- ST_Distance and ST_DWithin rank and filter every deck, and both operands were
  placeholders. Addresses now resolve through Mapbox (permanent=true, which is
  what licenses storing the coordinates), the server resolves points rather than
  trusting client-supplied lat/lng, and every stored point records how it was
  obtained. A `city`-precision base cannot reach the verification queue.

Quote -> booking -> review
- The commercial chain, minus payments. Accepting a quote is the only place a
  booking is created; confirming completion is what unlocks reviews and moves
  the pro's completed_jobs.
- Reviews publish double-blind with no sweeper: each is written with
  published_at already set to its embargo deadline and every read filters
  published_at <= now(), so it publishes itself. The second review pulls both
  forward. A silent counterparty cannot bury a bad review by never replying.

State machine changes, both deliberate
- booked -> matched: a cancelled booking is not a cancelled job.
- scheduled -> awaiting_confirmation: in_progress is optional, so a pro who
  never tapped Start can still say the work is done.

Test suite
- api tests ran files in parallel against one database and failed roughly one
  run in three on whichever file lost the race. Serialised, and three fixtures
  that grabbed "the first client" pinned to the seeded accounts.

Also includes work from a parallel session: admin verification queue, pro
public profile and reviews read path, notification sending, denormalised stats
recompute, search, and observability.

318 tests passing; typecheck and lint clean across 7 packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-21 06:29:59 -04:00
co-authored by Claude Opus 5
parent 8f3509d1dd
commit 974e312534
115 changed files with 19994 additions and 569 deletions
+180
View File
@@ -0,0 +1,180 @@
/**
* The geocoding surface, against the live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
*
* No Mapbox token is set in test, and that is deliberate: the behaviour worth
* pinning is what happens when the geocoder is NOT available. Every one of these
* paths used to end with the city centre silently stored as if it were an
* address, so "degrades honestly" is the property under test.
*/
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
const createCaller = createCallerFactory(appRouter);
type Session = import('../src/context').Session;
function callerFor(session: Session | null) {
return createCaller(createInnerContext({ db, session }));
}
const clientSession = (userId: string): Session => ({
userId,
role: 'client',
name: 'Test Client',
email: 'client@test',
phone: null,
verificationStatus: null,
});
const RUN = Math.random().toString(36).slice(2, 8);
const CITY = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
};
let client: string;
let plumberCat: string;
beforeAll(async () => {
const [row] = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, role)
VALUES ('Geo Probe', ${`geo-${RUN}@example.com`}, 'client')
RETURNING id
`);
client = row!.id;
const [cat] = await db.execute<{ id: string }>(
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
);
plumberCat = cat!.id;
});
afterAll(async () => {
await db.execute(sql`DELETE FROM users WHERE id = ${client}`);
await closePool();
});
describe('geocode.suggest', () => {
it('is not reachable without a session', async () => {
// Unlike pro.search this costs money per call, so the session is the first
// cost bound.
await expect(callerFor(null).geocode.suggest({ q: 'carrer' })).rejects.toThrow(/signed in/i);
});
it('caps the query length', async () => {
await expect(
callerFor(clientSession(client)).geocode.suggest({ q: 'x'.repeat(201) }),
).rejects.toThrow();
});
it('caps how many suggestions can be asked for', async () => {
await expect(
callerFor(clientSession(client)).geocode.suggest({ q: 'carrer', limit: 50 }),
).rejects.toThrow();
});
it('returns an empty list rather than failing when unconfigured', async () => {
// A provider outage must not take an address field — and therefore a whole
// form — down with it.
const result = await callerFor(clientSession(client)).geocode.suggest({ q: 'carrer de sants' });
expect(result.results).toEqual([]);
expect(result.configured).toBe(false);
});
});
describe('job.create resolves the point server-side', () => {
const base = {
categoryId: '',
title: 'Tap dripping in the bathroom',
description: 'The cold tap drips constantly and the washer looks perished.',
photos: [] as string[],
urgency: 'flexible' as const,
};
async function readJob(id: string) {
const [row] = await db.execute<{
precision: string;
address_text: string;
place_id: string | null;
lat: number;
lng: number;
}>(sql`
SELECT location_precision AS precision,
address_text,
location_place_id AS place_id,
ST_Y(location::geometry) AS lat,
ST_X(location::geometry) AS lng
FROM jobs WHERE id = ${id}
`);
return row!;
}
it('records an unresolvable address as city precision, and still posts', async () => {
// The heart of it. This used to store the city centre and label the row an
// address, so every distance computed from it was a fiction.
const job = await callerFor(clientSession(client)).job.create({
...base,
categoryId: plumberCat,
place: { source: 'none', label: 'Somewhere near the big roundabout' },
});
const row = await readJob(job.id);
expect(row.precision).toBe('city');
expect(row.place_id).toBeNull();
expect(Number(row.lat)).toBeCloseTo(CITY.lat, 4);
expect(Number(row.lng)).toBeCloseTo(CITY.lng, 4);
// What they typed survives — it is a note to the pro, just not a location.
expect(row.address_text).toBe('Somewhere near the big roundabout');
});
it('takes a device fix at its word but never calls it exact', async () => {
const job = await callerFor(clientSession(client)).job.create({
...base,
categoryId: plumberCat,
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' },
});
const row = await readJob(job.id);
// A handset fix is real, so the coordinates are kept as sent...
expect(Number(row.lat)).toBeCloseTo(41.4036, 4);
expect(Number(row.lng)).toBeCloseTo(2.1744, 4);
// ...but it is metres out on a good day, so it must not rank as a rooftop.
expect(row.precision).toBe('approximate');
});
it('falls back rather than trusting a placeId it cannot resolve', async () => {
// With no geocoder there is nothing to verify the id against, and an
// unverifiable id must not become a coordinate.
const job = await callerFor(clientSession(client)).job.create({
...base,
categoryId: plumberCat,
place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' },
});
const row = await readJob(job.id);
expect(row.precision).toBe('city');
expect(row.place_id).toBeNull();
});
it('no longer accepts raw coordinates at all', async () => {
// The old shape. Anyone could put a job anywhere on earth with it.
await expect(
callerFor(clientSession(client)).job.create({
...base,
categoryId: plumberCat,
// @ts-expect-error — the field is gone from the schema on purpose.
location: { lat: 0, lng: 0 },
addressText: 'Null Island',
}),
).rejects.toThrow();
});
});