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
+259
View File
@@ -0,0 +1,259 @@
import { TRPCError } from '@trpc/server';
import { and, eq, gt, sql } from 'drizzle-orm';
import { z } from 'zod';
import { recomputeProStats, schema } from '@linkder/db';
import { notify } from '@linkder/notify';
import { assertTransition } from '@linkder/shared';
import { proProcedure, router, verifiedProProcedure } from '../trpc';
/**
* The missing middle of the funnel.
*
* A right swipe writes a `pending` request and stops (`deck.swipe`). Until
* something accepts one, `matches` stays empty forever — which means no chat,
* no quote, no booking, and a pro whose inbox does not exist. This router is
* that step: the pro answers, and a match is the answer being yes.
*
* Expiry is lazy on purpose. A request past `expiresAt` is treated as expired
* wherever it is read and refused wherever it is acted on, rather than being
* swept by a cron that does not exist yet. The sweeper belongs with the M4
* worker; correctness must not wait for it.
*/
export const requestRouter = router({
/**
* The pro's inbox: jobs waiting on their answer.
*
* Not `verifiedProProcedure` — an unverified pro should be able to SEE what
* they are missing, which is the strongest argument for finishing
* verification. Acting on one is what needs the badge.
*/
mine: proProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
requestId: schema.requests.id,
expiresAt: schema.requests.expiresAt,
createdAt: schema.requests.createdAt,
jobId: schema.jobs.id,
title: schema.jobs.title,
description: schema.jobs.description,
urgency: schema.jobs.urgency,
photos: schema.jobs.photos,
budgetMinCents: schema.jobs.budgetMinCents,
budgetMaxCents: schema.jobs.budgetMaxCents,
categoryName: schema.categories.name,
// The pro needs to know how far it is before they answer. Metres from
// their own base, on the GiST index.
distanceM: sql<number>`ST_Distance(${schema.proProfiles.baseLocation}, ${schema.jobs.location})`,
})
.from(schema.requests)
.innerJoin(schema.jobs, eq(schema.jobs.id, schema.requests.jobId))
.innerJoin(schema.categories, eq(schema.categories.id, schema.jobs.categoryId))
.innerJoin(schema.proProfiles, eq(schema.proProfiles.userId, schema.requests.proId))
.where(
and(
eq(schema.requests.proId, ctx.session.userId),
eq(schema.requests.status, 'pending'),
// Lazy expiry: an unanswered request that ran out is not in the inbox.
gt(schema.requests.expiresAt, new Date()),
// A job the client has since cancelled is not worth answering.
eq(schema.jobs.status, 'open'),
),
)
.orderBy(schema.requests.expiresAt);
return rows.map((r) => ({ ...r, distanceM: Math.round(Number(r.distanceM)) }));
}),
/**
* "Yes, I want this job."
*
* Creates the match, which is what opens chat. Verified only: this is the
* first point where a pro touches a real customer, and `verifiedProProcedure`
* exists for exactly this.
*
* Everything happens under a lock on the request row. Two taps on a flaky
* connection are a read-then-write race, and the second one must not produce a
* second match — `matches.request_id` is UNIQUE, so the database would refuse
* it anyway, but a 500 from a constraint is not an answer a UI can render.
*/
accept: verifiedProProcedure
.input(z.object({ requestId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const result = await ctx.db.transaction(async (tx) => {
const [request] = await tx
.select()
.from(schema.requests)
.where(eq(schema.requests.id, input.requestId))
.for('update');
// 404 rather than 403 for someone else's request: a stranger must not be
// able to confirm it exists. Same rule as requireOwnedJob.
if (!request || request.proId !== ctx.session.userId) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' });
}
if (request.status !== 'pending') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message:
request.status === 'accepted'
? 'You already accepted this job.'
: 'This request is no longer open.',
});
}
if (request.expiresAt <= new Date()) {
// Record the expiry rather than leaving a stale `pending` row behind.
await tx
.update(schema.requests)
.set({ status: 'expired', respondedAt: new Date() })
.where(eq(schema.requests.id, request.id));
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'This request expired. The customer has moved on.',
});
}
const [job] = await tx
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, request.jobId))
.for('update');
if (!job) throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
if (job.status !== 'open' && job.status !== 'matched') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'This job is no longer taking offers.',
});
}
await tx
.update(schema.requests)
.set({ status: 'accepted', respondedAt: new Date() })
.where(eq(schema.requests.id, request.id));
const [match] = await tx
.insert(schema.matches)
.values({
requestId: request.id,
jobId: request.jobId,
proId: request.proId,
clientId: job.clientId,
})
.returning();
// A job with several interested pros is already `matched`; only the
// first acceptance moves it, and the graph is the authority on whether
// that move is legal.
if (job.status === 'open') {
assertTransition('job', 'open', 'matched');
await tx
.update(schema.jobs)
.set({ status: 'matched', updatedAt: new Date() })
.where(eq(schema.jobs.id, job.id));
}
await tx.insert(schema.auditLog).values({
actorId: ctx.session.userId,
action: 'request.accepted',
entity: 'request',
entityId: request.id,
metadata: { jobId: job.id, matchId: match!.id },
ip: ctx.ip,
});
return {
matchId: match!.id,
jobId: job.id,
clientId: job.clientId,
jobTitle: job.title,
};
});
/*
* Answering a request is what moves this pro's response rate, so the
* counters the deck ranks on are stale until this runs.
*
* AFTER the transaction, and swallowed: a failed stats refresh must never
* roll back an acceptance. The pro said yes, the match exists, and the
* next accept — or the nightly backfill — recomputes from source rows and
* repairs the number anyway, because recomputeProStats derives rather
* than increments.
*/
await recomputeProStats(ctx.db, ctx.session.userId).catch(() => {});
/*
* Tell the client somebody said yes.
*
* This is the message the whole funnel turns on: a client who posted a
* job and closed the app had no way of learning a pro was waiting, and
* the request expired while both sides assumed the other was thinking
* about it.
*
* Same placement and same reasoning as the stats refresh above — after
* the commit, and it cannot throw.
*/
await notify(ctx.db, result.clientId, {
kind: 'request.accepted',
proName: ctx.session.name ?? 'A pro',
jobTitle: result.jobTitle,
});
return { matchId: result.matchId, jobId: result.jobId };
}),
/**
* "No thanks."
*
* No match, no job transition — the client's other requests are unaffected and
* the job stays open for them. Deliberately allowed for an unverified pro:
* declining is how a pro keeps their inbox honest, and blocking it would just
* leave stale requests hanging until they expire.
*/
decline: proProcedure
.input(z.object({ requestId: z.string().uuid(), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const result = await ctx.db.transaction(async (tx) => {
const [request] = await tx
.select()
.from(schema.requests)
.where(eq(schema.requests.id, input.requestId))
.for('update');
if (!request || request.proId !== ctx.session.userId) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' });
}
if (request.status !== 'pending') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'This request is no longer open.',
});
}
await tx
.update(schema.requests)
.set({ status: 'declined', respondedAt: new Date() })
.where(eq(schema.requests.id, request.id));
await tx.insert(schema.auditLog).values({
actorId: ctx.session.userId,
action: 'request.declined',
entity: 'request',
entityId: request.id,
metadata: { jobId: request.jobId, reason: input.reason ?? null },
ip: ctx.ip,
});
return { declined: true as const };
});
// A decline is an answer too — it counts toward the response rate exactly
// as an acceptance does. Same placement and same reasoning as `accept`.
await recomputeProStats(ctx.db, ctx.session.userId).catch(() => {});
return result;
}),
});