From 66a6659ccdbbf9ae5a161c00454b8fc6c6ed7d66 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 15 Apr 2026 09:23:24 +0000 Subject: [PATCH 1/5] 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/5] 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/5] 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 From 5024cc489694fd3016e1181571182217d95791c8 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 16 Apr 2026 04:23:08 +0000 Subject: [PATCH 4/5] feat(GRO-653): add portal session middleware and server-side audit logging - Add validatePortalSession middleware that reads X-Impersonation-Session-Id header, queries impersonationSessions, and sets portalClientId + portalSessionId on the context - Add portalAudit middleware that logs all portal requests to impersonationAuditLogs table - Apply both middlewares to the portalRouter - Replace all getClientIdFromSession() calls with c.get("portalClientId") - Remove getClientIdFromSession() helper and inline session checks in waitlist routes - Consistent session.expiry > new Date() check across all routes Co-Authored-By: Paperclip --- apps/api/src/middleware/portalAudit.ts | 45 +++++++ apps/api/src/middleware/portalSession.ts | 40 +++++++ apps/api/src/routes/portal.ts | 144 ++++------------------- 3 files changed, 108 insertions(+), 121 deletions(-) create mode 100644 apps/api/src/middleware/portalAudit.ts create mode 100644 apps/api/src/middleware/portalSession.ts diff --git a/apps/api/src/middleware/portalAudit.ts b/apps/api/src/middleware/portalAudit.ts new file mode 100644 index 0000000..a18129d --- /dev/null +++ b/apps/api/src/middleware/portalAudit.ts @@ -0,0 +1,45 @@ +import type { MiddlewareHandler } from "hono"; +import { getDb, impersonationAuditLogs } from "@groombook/db"; +import type { PortalEnv } from "./portalSession.js"; + +/** + * Server-side audit logging middleware for portal routes. + * Applied after validatePortalSession in the middleware chain. + * + * After the route handler completes (await next()), inserts an audit log entry + * into impersonationAuditLogs: + * - sessionId: from c.get("portalSessionId") + * - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments") + * - pageVisited: c.req.path + * - metadata: { method, statusCode: c.res.status } + * + * Log entries are written for both success and error responses. + * Does NOT throw if audit logging fails — errors are logged but the user's + * request is not affected. + */ +export const portalAudit: MiddlewareHandler = async (c, next) => { + await next(); + + const sessionId = c.get("portalSessionId"); + if (!sessionId) return; + + const method = c.req.method; + const routePath = c.req.path; + const pageVisited = c.req.path; + const statusCode = c.res.status; + + try { + const db = getDb(); + await db + .insert(impersonationAuditLogs) + .values({ + sessionId, + action: `${method} ${routePath}`, + pageVisited, + metadata: { method, statusCode }, + }) + .returning(); + } catch (err) { + console.error("[portalAudit] Failed to write audit log:", err); + } +}; diff --git a/apps/api/src/middleware/portalSession.ts b/apps/api/src/middleware/portalSession.ts new file mode 100644 index 0000000..6dfdb03 --- /dev/null +++ b/apps/api/src/middleware/portalSession.ts @@ -0,0 +1,40 @@ +import type { MiddlewareHandler } from "hono"; +import { and, eq, getDb, impersonationSessions } from "@groombook/db"; + +export interface PortalEnv { + Variables: { + portalClientId: string; + portalSessionId: string; + }; +} + +/** + * Validates the X-Impersonation-Session-Id header against the impersonationSessions table. + * Must be applied to all portal routes. + * + * Reads x-session-id from request headers, queries impersonationSessions for a row where + * id = sessionId AND status = 'active', and checks session.expiresAt > new Date(). + * Returns 401 if session is invalid/missing/expired. + * On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id). + */ +export const validatePortalSession: MiddlewareHandler = async (c, next) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = getDb(); + const [session] = await db + .select() + .from(impersonationSessions) + .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + c.set("portalClientId", session.clientId); + c.set("portalSessionId", session.id); + await next(); +}; diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 8b10b56..293d961 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -3,31 +3,20 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; -import type { AppEnv } from "../middleware/rbac.js"; +import { validatePortalSession } from "../middleware/portalSession.js"; +import { portalAudit } from "../middleware/portalAudit.js"; +import type { PortalEnv } from "../middleware/portalSession.js"; -export const portalRouter = new Hono(); +export const portalRouter = new Hono(); -// ─── Session helper ─────────────────────────────────────────────────────────── - -async function getClientIdFromSession(sessionId: string | null | undefined): Promise { - if (!sessionId) return null; - const db = getDb(); - const [session] = await db - .select() - .from(impersonationSessions) - .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) - .limit(1); - if (!session || session.expiresAt <= new Date()) return null; - return session.clientId; -} +// Apply middleware to all portal routes +portalRouter.use("/*", validatePortalSession, portalAudit); // ─── GET routes ────────────────────────────────────────────────────────────── portalRouter.get("/me", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); if (!client) return c.json({ error: "Not found" }, 404); @@ -49,9 +38,7 @@ portalRouter.get("/services", async (c) => { portalRouter.get("/appointments", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const now = new Date(); const allAppts = await db @@ -101,9 +88,7 @@ portalRouter.get("/appointments", async (c) => { portalRouter.get("/pets", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); @@ -111,9 +96,7 @@ portalRouter.get("/pets", async (c) => { portalRouter.get("/invoices", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const invoiceIds = clientInvoices.map(i => i.id); @@ -148,12 +131,7 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -196,12 +174,7 @@ portalRouter.patch( portalRouter.post("/appointments/:id/confirm", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -250,12 +223,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => { portalRouter.post("/appointments/:id/cancel", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -319,28 +287,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - let clientId: string | null = null; - if (sessionId) { - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - if (session && session.expiresAt > new Date()) { - clientId = session.clientId; - } - } - - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .insert(waitlistEntries) @@ -364,26 +311,8 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); + const sessionId = c.get("portalSessionId"); const [existing] = await db .select() @@ -392,7 +321,7 @@ portalRouter.patch( .limit(1); if (!existing) return c.json({ error: "Not found" }, 404); - if (existing.clientId !== session.clientId) { + if (existing.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -414,26 +343,7 @@ portalRouter.patch( portalRouter.delete("/waitlist/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .select() @@ -442,7 +352,7 @@ portalRouter.delete("/waitlist/:id", async (c) => { .limit(1); if (!entry) return c.json({ error: "Not found" }, 404); - if (entry.clientId !== session.clientId) { + if (entry.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -475,9 +385,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const invoiceRows = await db .select() @@ -514,9 +422,7 @@ portalRouter.post( ); portalRouter.get("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const methods = await listPaymentMethods(clientId); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); @@ -524,9 +430,7 @@ portalRouter.get("/payment-methods", async (c) => { }); portalRouter.post("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const customerId = await getOrCreateStripeCustomer(clientId); @@ -539,9 +443,7 @@ portalRouter.post("/payment-methods", async (c) => { }); portalRouter.delete("/payment-methods/:id", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const paymentMethodId = c.req.param("id"); -- 2.52.0 From 5c2e13863ef77b91c3c4d518daaffa06ff6bf7c9 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 16 Apr 2026 10:18:56 +0000 Subject: [PATCH 5/5] fix(GRO-653): remove unused sessionId variable and and import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix lint errors flagged by QA: - Remove unused `sessionId` variable from PATCH waitlist handler - Remove unused `and` import from portal.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 293d961..d768bc8 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, inArray } from "@groombook/db"; +import { eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { validatePortalSession } from "../middleware/portalSession.js"; import { portalAudit } from "../middleware/portalAudit.js"; @@ -312,7 +312,6 @@ portalRouter.patch( const id = c.req.param("id"); const body = c.req.valid("json"); const clientId = c.get("portalClientId"); - const sessionId = c.get("portalSessionId"); const [existing] = await db .select() -- 2.52.0