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
+199
View File
@@ -0,0 +1,199 @@
import { eq } from 'drizzle-orm';
import { schema, type Db } from '@linkder/db';
import { isContactableEmail } from '@linkder/shared';
import { NotConfiguredError, sendEmail, sendSms } from './transport';
export { sendEmail, sendSms, NotConfiguredError } from './transport';
/**
* Telling people things happened.
*
* `notification.get/update` stored preferences that nothing read, and three
* TODOs marked the places a message belonged: a pro was never told a job had
* arrived, and a client was never told a pro had said yes. A marketplace whose
* two sides only find out by opening the app is a marketplace where the request
* expires unanswered.
*
* Sends are INLINE and best-effort, not queued. A queue means a second deployed
* process and Redis on the critical path, and none of that is worth standing up
* before a single message has ever been sent. What it costs is retries — so
* every attempt is written to `notification_deliveries`, because an inline send
* that fails silently leaves the product looking like it notifies people when it
* does not. When this moves into a worker, the call sites do not change: only
* the body of `notify()` does, and the queue reads its backlog from that table.
*/
export type NotificationKind =
| 'request.received'
| 'request.accepted'
| 'verification.submitted'
| 'verification.approved'
| 'verification.rejected';
/**
* Which preference governs which message, and whether it can be turned off.
*
* `null` means transactional: the outcome of something the person did, which
* they cannot unsubscribe from without the product breaking its promise to
* them. A pro who switched off marketing must still be told their account was
* approved.
*/
const GOVERNED_BY: Record<NotificationKind, keyof typeof PREFERENCE_DEFAULTS | null> = {
'request.received': 'smsNewRequest',
'request.accepted': 'pushRequests',
'verification.submitted': null,
'verification.approved': null,
'verification.rejected': null,
};
/** Mirrors the table defaults, so a user with no row is not a special case. */
const PREFERENCE_DEFAULTS = {
smsNewRequest: true,
smsBookingReminder: true,
smsMarketing: false,
emailReceipts: true,
emailMarketing: false,
pushMessages: true,
pushRequests: true,
} as const;
export interface Message {
/** Subject line for email. SMS ignores it. */
subject: string;
body: string;
}
export type NotifyInput =
| { kind: 'request.received'; trade: string; distanceM: number; expiresInHours: number }
| { kind: 'request.accepted'; proName: string; jobTitle: string }
| { kind: 'verification.submitted'; proName: string }
| { kind: 'verification.approved' }
| { kind: 'verification.rejected'; notes: string };
/**
* The copy.
*
* Kept together rather than beside each call site: these are the only words
* this product says to somebody who is not currently looking at it, and they
* have to sound like one product. Short enough for a single SMS segment where
* SMS is the channel — a message that splits costs twice and arrives out of
* order on some carriers.
*/
function render(input: NotifyInput): Message {
switch (input.kind) {
case 'request.received': {
const km = input.distanceM < 1000 ? '<1' : Math.round(input.distanceM / 1000);
return {
subject: `New ${input.trade} job ${km}km away`,
body:
`Linkder: a ${input.trade.toLowerCase()} job ${km}km away is waiting on your answer. ` +
`You have ${input.expiresInHours} hours before it goes to someone else.`,
};
}
case 'request.accepted':
return {
subject: `${input.proName} wants your job`,
body: `Linkder: ${input.proName} said yes to "${input.jobTitle}". Open the app to agree a price.`,
};
case 'verification.submitted':
return {
subject: `${input.proName} submitted for review`,
body: `${input.proName} has finished onboarding and is waiting in the review queue.`,
};
case 'verification.approved':
return {
subject: 'You are live on Linkder',
body: 'Linkder: you are verified. Customers in your area can see and swipe your card now.',
};
case 'verification.rejected':
return {
subject: 'We could not approve your account yet',
body: `Linkder: we could not approve your account yet. ${input.notes} Fix it and submit again.`,
};
}
}
type Channel = 'sms' | 'email';
async function record(
db: Db,
userId: string,
kind: NotificationKind,
channel: Channel | 'none',
status: 'sent' | 'skipped' | 'failed',
detail?: string,
): Promise<void> {
await db
.insert(schema.notificationDeliveries)
.values({ userId, kind, channel, status, detail: detail ?? null })
// Never let the bookkeeping be the thing that throws.
.catch(() => undefined);
}
/**
* Send one notification, honouring the recipient's preferences.
*
* Never throws. Every caller invokes this from outside a transaction and after
* the thing it is about has already committed, so a failure here has to be
* recorded and swallowed — a pro who accepted a job has accepted it whether or
* not the client's SMS went out.
*/
export async function notify(
db: Db,
userId: string,
input: NotifyInput,
): Promise<{ status: 'sent' | 'skipped' | 'failed'; channel: Channel | 'none' }> {
const kind = input.kind;
let channel: Channel | 'none' = 'none';
try {
const user = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
columns: { phoneNumber: true, email: true, phoneNumberVerified: true },
});
if (!user) {
await record(db, userId, kind, 'none', 'skipped', 'no such user');
return { status: 'skipped', channel: 'none' };
}
const governedBy = GOVERNED_BY[kind];
if (governedBy) {
const prefs = await db.query.notificationPreferences.findFirst({
where: eq(schema.notificationPreferences.userId, userId),
});
const allowed = prefs ? prefs[governedBy] : PREFERENCE_DEFAULTS[governedBy];
if (!allowed) {
await record(db, userId, kind, 'none', 'skipped', `turned off (${governedBy})`);
return { status: 'skipped', channel: 'none' };
}
}
const message = render(input);
/*
* SMS first where we have a verified number, email otherwise.
*
* Not a preference: it is who these people are. A client may never have
* given us an email — they signed up with a phone and got a synthetic
* address — while a pro is required to supply a real one for payout and tax
* records. Picking by what actually reaches them beats picking by channel.
*/
if (user.phoneNumber && user.phoneNumberVerified) {
channel = 'sms';
await sendSms(user.phoneNumber, message.body);
} else if (isContactableEmail(user.email)) {
channel = 'email';
await sendEmail({ to: user.email, subject: message.subject, text: message.body });
} else {
await record(db, userId, kind, 'none', 'skipped', 'no verified phone and no real email');
return { status: 'skipped', channel: 'none' };
}
await record(db, userId, kind, channel, 'sent');
return { status: 'sent', channel };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await record(db, userId, kind, channel, 'failed', detail.slice(0, 500));
return { status: 'failed', channel };
}
}