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>
200 lines
7.3 KiB
TypeScript
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 };
|
|
}
|
|
}
|