From 66a6659ccdbbf9ae5a161c00454b8fc6c6ed7d66 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 15 Apr 2026 09:23:24 +0000 Subject: [PATCH 1/3] feat(GRO-600): extend reminder scheduler to send SMS alongside email - Add SMS opt-in fields to clients schema (smsOptIn, smsConsentDate, smsOptOutDate, smsConsentText) - Add channel column to reminderLogs with per-channel idempotency - Create SMS service with Telnyx SDK integration and E.164 validation - Update reminders service to conditionally send SMS to opted-in clients - Add TCPA opt-out text to SMS reminders - Graceful degradation: catch SMS errors without blocking email - Fix: use clients.phone instead of non-existent clients.phoneE164 - Update clients route to expose SMS fields in API - Add telnyx dependency to API package - Create database migration 0028_sms_reminders Co-Authored-By: Paperclip --- apps/api/package.json | 1 + apps/api/src/routes/clients.ts | 11 +- apps/api/src/services/reminders.ts | 110 +++++++++----- apps/api/src/services/sms.ts | 142 ++++++++++++++++++ apps/api/src/types/telnyx.d.ts | 19 +++ packages/db/migrations/0028_sms_reminders.sql | 12 ++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/factories.ts | 4 + packages/db/src/schema.ts | 9 +- pnpm-lock.yaml | 96 ++++++------ 10 files changed, 316 insertions(+), 95 deletions(-) create mode 100644 apps/api/src/services/sms.ts create mode 100644 apps/api/src/types/telnyx.d.ts create mode 100644 packages/db/migrations/0028_sms_reminders.sql diff --git a/apps/api/package.json b/apps/api/package.json index 05f46ac..e8d4488 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,7 @@ "node-cron": "^3.0.3", "nodemailer": "^6.9.16", "stripe": "^22.0.0", + "telnyx": "^1.23.0", "zod": "^4.3.6" }, diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index 053b975..9a375ce 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -12,6 +12,8 @@ const createClientSchema = z.object({ phone: z.string().max(50).optional(), address: z.string().max(500).optional(), notes: z.string().max(2000).optional(), + smsOptIn: z.boolean().optional(), + smsConsentText: z.string().max(1000).optional(), }); @@ -95,6 +97,7 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => { // Update a client (including status changes) const patchClientSchema = createClientSchema.partial().extend({ status: z.enum(["active", "disabled"]).optional(), + smsOptOut: z.boolean().optional(), }); clientsRouter.patch( @@ -107,13 +110,19 @@ clientsRouter.patch( const setValues: Record = { ...body, updatedAt: now }; - // When disabling, set disabledAt; when re-enabling, clear it if (body.status === "disabled") { setValues.disabledAt = now; } else if (body.status === "active") { setValues.disabledAt = null; } + if (body.smsOptOut === true) { + setValues.smsOptIn = false; + setValues.smsOptOutDate = now; + delete setValues.smsOptOut; + } + delete setValues.smsOptOut; + const [row] = await db .update(clients) .set(setValues) diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 442b6c3..5aa2abc 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -18,9 +18,10 @@ import { buildReminderEmail, sendEmail, } from "./email.js"; +import { smsSend } from "./sms.js"; + +const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply."; -// How many hours before the appointment to send each reminder. -// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2). function getReminderWindows(): { label: string; hours: number }[] { const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); @@ -30,20 +31,14 @@ function getReminderWindows(): { label: string; hours: number }[] { ]; } -// Checks for upcoming appointments that need reminders and sends them. -// Runs every minute — idempotent via reminder_logs unique constraint. export async function runReminderCheck(): Promise { const db = getDb(); const now = new Date(); for (const window of getReminderWindows()) { - // Target window: appointments starting between (hours - 1) and hours from now. - // Running every minute means we check a 1-minute slice; the 1-hour window - // ensures we catch appointments that started between heartbeats. const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000); const windowEnd = new Date(now.getTime() + window.hours * 3600_000); - // Find upcoming appointments in this time window that haven't been cancelled/completed const upcoming = await db .select({ id: appointments.id, @@ -65,23 +60,38 @@ export async function runReminderCheck(): Promise { ); for (const appt of upcoming) { - // Check if reminder already sent (unique constraint prevents double-send) - const existing = await db + const [emailLog] = await db .select({ id: reminderLogs.id }) .from(reminderLogs) .where( and( eq(reminderLogs.appointmentId, appt.id), - eq(reminderLogs.reminderType, window.label) + eq(reminderLogs.reminderType, window.label), + eq(reminderLogs.channel, "email") ) ) .limit(1); - if (existing.length > 0) continue; // already sent + const [smsLog] = await db + .select({ id: reminderLogs.id }) + .from(reminderLogs) + .where( + and( + eq(reminderLogs.appointmentId, appt.id), + eq(reminderLogs.reminderType, window.label), + eq(reminderLogs.channel, "sms") + ) + ) + .limit(1); - // Fetch related records for the email const [client] = await db - .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .select({ + name: clients.name, + email: clients.email, + emailOptOut: clients.emailOptOut, + smsOptIn: clients.smsOptIn, + phone: clients.phone, + }) .from(clients) .where(eq(clients.id, appt.clientId)) .limit(1); @@ -112,8 +122,6 @@ export async function runReminderCheck(): Promise { if (!pet || !service) continue; - // Ensure the appointment has a confirmation token before sending the reminder. - // Generate one if it doesn't have one yet (e.g. pre-existing appointments). let confirmationToken = appt.confirmationToken; if (!confirmationToken) { confirmationToken = randomBytes(32).toString("hex"); @@ -123,35 +131,59 @@ export async function runReminderCheck(): Promise { .where(eq(appointments.id, appt.id)); } - const sent = await sendEmail( - buildReminderEmail( - client.email, - { - clientName: client.name, - petName: pet.name, - serviceName: service.name, - groomerName, - startTime: appt.startTime, - }, - window.hours, - confirmationToken - ) - ); + if (!emailLog) { + const sent = await sendEmail( + buildReminderEmail( + client.email, + { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + groomerName, + startTime: appt.startTime, + }, + window.hours, + confirmationToken + ) + ); - if (sent) { - // Record send — ignore conflicts (race condition between instances) - await db - .insert(reminderLogs) - .values({ appointmentId: appt.id, reminderType: window.label }) - .onConflictDoNothing(); + if (sent) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "email" }) + .onConflictDoNothing(); + } + } + + if (!smsLog && client.smsOptIn && client.phone) { + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; + const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; + const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; + const smsBody = [ + `Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`, + `Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`, + `Confirm: ${confirmUrl}`, + `Cancel: ${cancelUrl}`, + TCPA_OPT_OUT, + ].join(". "); + try { + const smsOk = await smsSend(client.phone, smsBody); + if (smsOk) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" }) + .onConflictDoNothing(); + } + } catch (err) { + console.error("[reminders] SMS send failed:", err); + } } } } } -// Starts the cron scheduler. Call once at server startup. export function startReminderScheduler(): void { - // Run every minute cron.schedule("* * * * *", () => { runReminderCheck().catch((err) => { console.error("[reminders] Error during reminder check:", err); @@ -163,8 +195,6 @@ export function startReminderScheduler(): void { console.log("[reminders] Reminder scheduler started"); } -// Deletes expired sessions from the database. -// Runs every minute alongside reminder checks. export async function runSessionCleanup(): Promise { const db = getDb(); const now = new Date(); diff --git a/apps/api/src/services/sms.ts b/apps/api/src/services/sms.ts new file mode 100644 index 0000000..5be4009 --- /dev/null +++ b/apps/api/src/services/sms.ts @@ -0,0 +1,142 @@ +import { Telnyx } from "telnyx"; +import { createHmac } from "crypto"; + +export interface SmsProvider { + sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>; + validateWebhookSignature(req: Request): boolean; +} + +interface TelnyxSmsResult { + message_id: string; + status: string; +} + +function createTelnyxClient(): Telnyx | null { + const apiKey = process.env.TELNYX_API_KEY; + if (!apiKey) return null; + return new Telnyx(apiKey); +} + +let _client: Telnyx | null | undefined; + +function getClient(): Telnyx | null { + if (_client === undefined) _client = createTelnyxClient(); + return _client; +} + +function getFromNumber(): string | null { + return process.env.TELNYX_FROM_NUMBER ?? null; +} + +function isE164(phone: string): boolean { + return /^\+[1-9]\d{7,14}$/.test(phone); +} + +export async function sendSms( + to: string, + body: string, + mediaUrls?: string[] +): Promise<{ messageId: string; status: string }> { + const client = getClient(); + if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY."); + + const from = getFromNumber(); + if (!from) throw new Error("TELNYX_FROM_NUMBER is not set"); + + if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`); + if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`); + + const payload: Record = { + from, + to, + body, + }; + + if (mediaUrls && mediaUrls.length > 0) { + payload.media_urls = mediaUrls; + } + + const result = await client.messages.create(payload as Record); + const smsResult = result.data as unknown as TelnyxSmsResult; + return { + messageId: smsResult.message_id, + status: smsResult.status, + }; +} + +export class TelnyxProvider implements SmsProvider { + async sendSms( + to: string, + body: string, + mediaUrls?: string[] + ): Promise<{ messageId: string; status: string }> { + return sendSms(to, body, mediaUrls); + } + + validateWebhookSignature(req: Request): boolean { + const secret = process.env.TELNYX_WEBHOOK_SECRET; + if (!secret) return false; + + const signature = req.headers.get("telnyx-signature"); + if (!signature) return false; + + const payload = JSON.stringify(req.body); + + try { + const hmac = createHmac("sha256", secret); + const expected = `sha256=${hmac.update(payload).digest("hex")}`; + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + + if (sigBuf.length !== expBuf.length) return false; + + let diff = 0; + for (let i = 0; i < sigBuf.length; i++) { + const sigByte = sigBuf[i] ?? 0; + const expByte = expBuf[i] ?? 0; + diff |= sigByte ^ expByte; + } + return diff === 0; + } catch { + return false; + } + } +} + +let _provider: SmsProvider | null | undefined; + +export function createSmsProvider(): SmsProvider | null { + if (_provider === undefined) { + if (process.env.SMS_ENABLED !== "true") { + _provider = null; + return null; + } + switch (process.env.SMS_PROVIDER) { + case "telnyx": { + const client = getClient(); + if (!client) { + _provider = null; + return null; + } + _provider = new TelnyxProvider(); + break; + } + default: + _provider = null; + } + } + return _provider; +} + +export async function smsSend( + to: string, + body: string, + mediaUrls?: string[] +): Promise { + const provider = createSmsProvider(); + if (!provider) return false; + + await provider.sendSms(to, body, mediaUrls); + return true; +} diff --git a/apps/api/src/types/telnyx.d.ts b/apps/api/src/types/telnyx.d.ts new file mode 100644 index 0000000..097916e --- /dev/null +++ b/apps/api/src/types/telnyx.d.ts @@ -0,0 +1,19 @@ +declare module "telnyx" { + export interface MessageResult { + data: unknown; + } + + export interface MessagesCreateParams { + from: string; + to: string; + body: string; + media_urls?: string[]; + } + + export class Telnyx { + constructor(apiKey: string); + messages: { + create(params: Record): Promise; + }; + } +} diff --git a/packages/db/migrations/0028_sms_reminders.sql b/packages/db/migrations/0028_sms_reminders.sql new file mode 100644 index 0000000..4c63eb2 --- /dev/null +++ b/packages/db/migrations/0028_sms_reminders.sql @@ -0,0 +1,12 @@ +-- SMS opt-in fields for clients +ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean NOT NULL DEFAULT false; +ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp; +ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp; +ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text; + +-- Add channel column to reminder_logs with default 'email' +ALTER TABLE "reminder_logs" ADD COLUMN "channel" text NOT NULL DEFAULT 'email'; + +-- Drop the old unique constraint and recreate with channel +ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique"; +ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index c5ad96e..8db9b8d 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1775655267192, "tag": "0027_refunds", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1775741667192, + "tag": "0028_sms_reminders", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index 530baf8..88609f2 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -71,6 +71,10 @@ export function buildClient(overrides: Partial = {}): ClientRow { address: "1 Main St, Springfield, CA 90000", notes: null, emailOptOut: false, + smsOptIn: false, + smsConsentDate: null, + smsOptOutDate: null, + smsConsentText: null, stripeCustomerId: null, status: "active", disabledAt: null, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 375bf75..f5c2521 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -110,6 +110,10 @@ export const clients = pgTable("clients", { address: text("address"), notes: text("notes"), emailOptOut: boolean("email_opt_out").notNull().default(false), + smsOptIn: boolean("sms_opt_in").notNull().default(false), + smsConsentDate: timestamp("sms_consent_date"), + smsOptOutDate: timestamp("sms_opt_out_date"), + smsConsentText: text("sms_consent_text"), stripeCustomerId: text("stripe_customer_id"), status: clientStatusEnum("status").notNull().default("active"), disabledAt: timestamp("disabled_at"), @@ -321,6 +325,7 @@ export const refunds = pgTable( // Tracks which reminder emails have been sent per appointment (prevents duplicates). // reminder_type values: "confirmation", "24h", "2h" +// channel values: "email", "sms" export const reminderLogs = pgTable( "reminder_logs", { @@ -330,9 +335,11 @@ export const reminderLogs = pgTable( .references(() => appointments.id, { onDelete: "cascade" }), // "confirmation" | "24h" | "2h" reminderType: text("reminder_type").notNull(), + // "email" | "sms" + channel: text("channel").notNull().default("email"), sentAt: timestamp("sent_at").notNull().defaultNow(), }, - (t) => [unique().on(t.appointmentId, t.reminderType)] + (t) => [unique().on(t.appointmentId, t.reminderType, t.channel)] ); // ─── Impersonation ────────────────────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6396f90..22f713a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: stripe: specifier: ^22.0.0 version: 22.0.1(@types/node@22.19.15) + telnyx: + specifier: ^1.23.0 + version: 1.27.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -177,7 +180,7 @@ importers: version: 22.19.15 drizzle-kit: specifier: ^0.30.4 - version: 0.30.6 + version: 0.30.4 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -1696,9 +1699,6 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} - '@petamoriken/float16@3.9.3': - resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2830,8 +2830,8 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - drizzle-kit@0.30.6: - resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} + drizzle-kit@0.30.4: + resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==} hasBin: true drizzle-orm@0.38.4: @@ -2955,10 +2955,6 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -3162,11 +3158,6 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - gel@2.2.0: - resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==} - engines: {node: '>= 18.0.0'} - hasBin: true - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3434,10 +3425,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -3619,6 +3606,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3851,6 +3841,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -4046,10 +4040,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -4188,6 +4178,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + telnyx@1.27.0: + resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==} + engines: {node: ^6 || >=8} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -4262,6 +4256,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4351,6 +4348,10 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} @@ -4487,11 +4488,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@4.0.0: - resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} - engines: {node: ^16.13.0 || >=18.0.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -6223,8 +6219,6 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} - '@petamoriken/float16@3.9.3': {} - '@pkgjs/parseargs@0.11.0': optional: true @@ -7420,13 +7414,12 @@ snapshots: dom-accessibility-api@0.6.3: {} - drizzle-kit@0.30.6: + drizzle-kit@0.30.4: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.19.12 esbuild-register: 3.6.0(esbuild@0.19.12) - gel: 2.2.0 transitivePeerDependencies: - supports-color @@ -7463,8 +7456,6 @@ snapshots: entities@6.0.1: {} - env-paths@3.0.0: {} - es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 @@ -7826,17 +7817,6 @@ snapshots: functions-have-names@1.2.3: {} - gel@2.2.0: - dependencies: - '@petamoriken/float16': 3.9.3 - debug: 4.4.3 - env-paths: 3.0.0 - semver: 7.7.4 - shell-quote: 1.8.3 - which: 4.0.0 - transitivePeerDependencies: - - supports-color - generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -8101,8 +8081,6 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.5: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -8271,6 +8249,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.isplainobject@4.0.6: {} + lodash.merge@4.6.2: {} lodash.sortby@4.7.0: {} @@ -8469,6 +8449,10 @@ snapshots: punycode@2.3.1: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -8703,8 +8687,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.3: {} - side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -8858,6 +8840,14 @@ snapshots: tapable@2.3.0: {} + telnyx@1.27.0: + dependencies: + lodash.isplainobject: 4.0.6 + qs: 6.15.1 + safe-buffer: 5.2.1 + tweetnacl: 1.0.3 + uuid: 9.0.1 + temp-dir@2.0.0: {} tempy@0.6.0: @@ -8928,6 +8918,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -9024,6 +9016,8 @@ snapshots: uuid@8.3.2: {} + uuid@9.0.1: {} + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 @@ -9201,10 +9195,6 @@ snapshots: dependencies: isexe: 2.0.0 - which@4.0.0: - dependencies: - isexe: 3.1.5 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 -- 2.52.0 From 16dd51352178f9b86ee7d8bf5a023e7ff7966036 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 15 Apr 2026 09:37:51 +0000 Subject: [PATCH 2/3] fix(seed): populate userId for UAT staff and SEED_ADMIN_EMAIL staff GRO-666: resolveStaffMiddleware returns 403 for UAT users because staff records have NULL userId after seed. This change populates userId (and oidcSub) for all staff created via seedKnownUsers() and the main seed path using the same value as the OIDC sub. Co-Authored-By: Paperclip --- packages/db/src/seed.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index ebb84a9..9d0808c 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -398,6 +398,8 @@ async function seedKnownUsers() { id: ADMIN_STAFF_ID, name: adminName, email: adminEmail, + oidcSub: adminEmail, + userId: adminEmail, role: "manager", isSuperUser: true, active: true, @@ -424,6 +426,7 @@ async function seedKnownUsers() { name: "UAT Super User", email: "uat-super@groombook.dev", oidcSub: uatSuperOidcSub, + userId: uatSuperOidcSub, role: "manager", isSuperUser: true, active: true, @@ -450,6 +453,7 @@ async function seedKnownUsers() { name: "UAT Staff Groomer", email: "uat-groomer@groombook.dev", oidcSub: uatStaffOidcSub, + userId: uatStaffOidcSub, role: "groomer", isSuperUser: false, active: true, @@ -612,6 +616,8 @@ async function seed() { id: ADMIN_STAFF_ID, name: adminName, email: adminEmail, + oidcSub: adminEmail, + userId: adminEmail, role: "manager", isSuperUser: true, active: true, -- 2.52.0 From 85cff19c59ce5ab300e9737c6726fdddece3c92d Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 15 Apr 2026 15:25:20 +0000 Subject: [PATCH 3/3] fix(GRO-666): make migration 0028 idempotent to resolve E2E failure - Add IF NOT EXISTS to all ADD COLUMN statements (schema already has these columns) - Use DROP CONSTRAINT IF EXISTS for both possible auto-generated constraint names - Idempotent: safe to re-run on databases that already have the schema changes cc @cpfarhood Co-Authored-By: Paperclip --- packages/db/migrations/0028_sms_reminders.sql | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/db/migrations/0028_sms_reminders.sql b/packages/db/migrations/0028_sms_reminders.sql index 4c63eb2..1e7314b 100644 --- a/packages/db/migrations/0028_sms_reminders.sql +++ b/packages/db/migrations/0028_sms_reminders.sql @@ -1,12 +1,15 @@ --- SMS opt-in fields for clients -ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean NOT NULL DEFAULT false; -ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp; -ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp; -ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text; +-- SMS opt-in fields for clients (idempotent) +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text; --- Add channel column to reminder_logs with default 'email' -ALTER TABLE "reminder_logs" ADD COLUMN "channel" text NOT NULL DEFAULT 'email'; +-- Add channel column to reminder_logs with default 'email' (idempotent) +ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email'; --- Drop the old unique constraint and recreate with channel -ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique"; +-- Drop old unique constraints if they exist (idempotent) +ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key"; +ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique"; + +-- Add new unique constraint with channel ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel"); -- 2.52.0