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
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@linkder/notify",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@linkder/db": "workspace:*",
"@linkder/shared": "workspace:*",
"drizzle-orm": "0.38.4"
},
"devDependencies": {
"dotenv": "16.4.7",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
}
}
+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 };
}
}
+97
View File
@@ -0,0 +1,97 @@
import { isContactableEmail } from '@linkder/shared';
/**
* The two ways we can put a message in front of somebody.
*
* Both follow the same rule, inherited from the OTP sender this was lifted out
* of: with no provider configured, development logs to the console and
* production THROWS. A deploy missing its credentials must fail loudly rather
* than quietly printing customer messages into a log aggregator and reporting
* success.
*/
const isProduction = process.env.NODE_ENV === 'production';
export class NotConfiguredError extends Error {}
function missingProvider(what: string, vars: string): never | void {
if (isProduction) {
throw new NotConfiguredError(
`${what} is not configured (${vars}). Refusing to fall back to console logging in production.`,
);
}
}
/**
* Send an SMS through Twilio.
*
* The phone number IS the credential on this platform, so `to` must already be
* E.164 — normalise with `toE164()` before calling. Twilio will accept other
* shapes and deliver to a different handset than the one we think we are
* talking to.
*/
export async function sendSms(to: string, body: string): Promise<void> {
const sid = process.env.TWILIO_ACCOUNT_SID;
const token = process.env.TWILIO_AUTH_TOKEN;
const from = process.env.TWILIO_FROM_NUMBER;
if (!sid || !token || !from) {
missingProvider('SMS', 'TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER');
console.info(`\n [dev SMS] to ${to}: ${body}\n`);
return;
}
const response = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${sid}:${token}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ To: to, From: from, Body: body }),
});
if (!response.ok) {
// The body is not logged: an SMS we send can quote a job title or an
// address, and this string ends up in an error tracker.
const detail = await response.text().catch(() => '<no body>');
throw new Error(`Twilio rejected the message (${response.status}): ${detail}`);
}
}
/**
* Send an email through Resend.
*
* Refuses a synthetic address rather than bouncing one. A phone signup has no
* real mailbox — `syntheticEmailFor()` gives them `+34…@phone.linkder.local` —
* and posting that to a provider is not merely useless: it is a hard bounce
* against our sending reputation, and at volume that costs us delivery to the
* addresses that are real.
*/
export async function sendEmail(input: {
to: string | null | undefined;
subject: string;
text: string;
}): Promise<void> {
if (!isContactableEmail(input.to)) {
throw new NotConfiguredError('No reachable email address for this user.');
}
const key = process.env.RESEND_API_KEY;
const from = process.env.EMAIL_FROM;
if (!key || !from) {
missingProvider('Email', 'RESEND_API_KEY / EMAIL_FROM');
console.info(`\n [dev email] to ${input.to}: ${input.subject}\n ${input.text}\n`);
return;
}
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to: [input.to], subject: input.subject, text: input.text }),
});
if (!response.ok) {
const detail = await response.text().catch(() => '<no body>');
throw new Error(`Resend rejected the message (${response.status}): ${detail}`);
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Integration tests for the notification dispatcher, against the live database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
*
* Three properties carry this module, and all three fail silently if broken —
* which is the whole reason `notification_deliveries` exists:
*
* 1. It never throws. Every caller runs it after a committed transaction, so a
* provider outage must not turn an accepted job into a 500.
* 2. It honours the preference that governs each kind, and ignores preferences
* for the transactional ones nobody may unsubscribe from.
* 3. It picks a channel that can actually reach the person — a phone signup has
* a synthetic email address, and posting that to a provider is a hard bounce
* against our sending reputation.
*
* No provider is configured under test, so `sendSms`/`sendEmail` take their
* development path and log instead of calling out. That is the behaviour being
* asserted: in production the same missing config throws, deliberately.
*/
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 { notify } = await import('../src/index');
const RUN = Math.random().toString(36).slice(2, 8);
/** Verified phone, no real email — a client who signed up by SMS. */
let phoneUser: string;
/** Real email, no phone — a pro who signed up with Google. */
let emailUser: string;
/** Neither. Nothing can reach them. */
let unreachable: string;
async function deliveries(userId: string) {
return db.execute<{ kind: string; channel: string; status: string; detail: string | null }>(sql`
SELECT kind, channel, status, detail FROM notification_deliveries
WHERE user_id = ${userId} ORDER BY created_at
`);
}
beforeAll(async () => {
// A phone signup: real number, and the synthetic address they were given
// because they never had a mailbox to give us.
const phone = `+34999${String(Math.floor(Math.random() * 1e6)).padStart(6, '0')}`;
const [a] = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, phone, phone_verified, role)
VALUES ('Notify Phone', ${`${phone}@phone.linkder.local`}, ${phone}, true, 'client')
RETURNING id
`);
phoneUser = a!.id;
const [b] = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, role)
VALUES ('Notify Email', ${`notify-${RUN}@example.com`}, 'pro')
RETURNING id
`);
emailUser = b!.id;
const [c] = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, role)
VALUES ('Notify Nobody', ${`+3400000${RUN}@phone.linkder.local`}, 'client')
RETURNING id
`);
unreachable = c!.id;
});
afterAll(async () => {
await db.execute(sql`DELETE FROM users WHERE id IN (${phoneUser}, ${emailUser}, ${unreachable})`);
await closePool();
});
describe('notify', () => {
it('sends over SMS when there is a verified number', async () => {
const result = await notify(db, phoneUser, {
kind: 'request.received',
trade: 'Plumber',
distanceM: 2400,
expiresInHours: 12,
});
expect(result).toEqual({ status: 'sent', channel: 'sms' });
const rows = await deliveries(phoneUser);
expect(rows.at(-1)).toMatchObject({ kind: 'request.received', channel: 'sms', status: 'sent' });
});
it('falls back to email when there is no phone', async () => {
const result = await notify(db, emailUser, { kind: 'verification.approved' });
expect(result).toEqual({ status: 'sent', channel: 'email' });
});
it('skips someone nothing can reach, rather than bouncing a synthetic address', async () => {
// `+34…@phone.linkder.local` is what a phone signup gets when they never
// give us an address. Mailing it is a hard bounce against our reputation.
const result = await notify(db, unreachable, { kind: 'verification.approved' });
expect(result.status).toBe('skipped');
const rows = await deliveries(unreachable);
expect(rows.at(-1)!.detail).toMatch(/no verified phone/i);
});
it('honours the preference that governs a kind', async () => {
await db.execute(sql`
INSERT INTO notification_preferences (user_id, sms_new_request)
VALUES (${phoneUser}, false)
ON CONFLICT (user_id) DO UPDATE SET sms_new_request = false
`);
const result = await notify(db, phoneUser, {
kind: 'request.received',
trade: 'Plumber',
distanceM: 900,
expiresInHours: 12,
});
expect(result.status).toBe('skipped');
expect((await deliveries(phoneUser)).at(-1)!.detail).toMatch(/turned off/i);
});
it('sends a transactional message even with everything switched off', async () => {
await db.execute(sql`
UPDATE notification_preferences
SET sms_new_request = false, push_requests = false, email_receipts = false,
sms_marketing = false, email_marketing = false
WHERE user_id = ${phoneUser}
`);
// Being approved is the outcome of something they did. A pro who muted
// everything must still be told their account went live, or the product
// has broken its promise to them.
const result = await notify(db, phoneUser, { kind: 'verification.approved' });
expect(result.status).toBe('sent');
});
it('records a failure instead of throwing', async () => {
// A user id that does not exist stands in for any lookup that comes back
// empty. The contract is that callers never have to catch.
const missing = '00000000-0000-0000-0000-000000000000';
const result = await notify(db, missing, { kind: 'verification.approved' });
expect(result.status).toBe('skipped');
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'node', include: ['test/**/*.test.ts'] },
});