import { eq } from 'drizzle-orm'; import { schema, type Db } from '@linkdr/db'; import { isContactableEmail } from '@linkdr/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 = { '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: `Linkdr: 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: `Linkdr: ${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 Linkdr', body: 'Linkdr: 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: `Linkdr: 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 { 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 }; } }