Files
linkder/packages/notify/src/index.ts
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

200 lines
7.3 KiB
TypeScript

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<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:
`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<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 };
}
}