From abee344ca49437acce66283853db92c31e739c07 Mon Sep 17 00:00:00 2001 From: "the-dogfather-cto[bot]" <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:58:00 +0000 Subject: [PATCH 01/10] =?UTF-8?q?Promote=20dev=20=E2=86=92=20uat:=20ARIA?= =?UTF-8?q?=20modal=20fix=20+=20tip=20split=20atomicity=20(#335)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(GRO-785): validate tip split totals before marking invoice paid - PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits exist or splits don't sum to 100% - POST /invoices/:id/tip-splits now returns 400 (not 422) on validation failure via router-level ZodError handler Co-Authored-By: Paperclip * feat(GRO-786): add ARIA label attributes to Modal dialog component - Update Modal component to accept title and titleStyle props - Add role="dialog", aria-modal="true", and aria-labelledby attributes - Use useId() to generate stable ID for title heading association - Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet, Log Grooming Visit, Permanently Delete Client) with title props - Delete modal passes titleStyle for red color on warning Co-Authored-By: Paperclip * fix(GRO-786): remove duplicate dialog role and restore focus trap - Remove role="dialog" and aria-modal="true" from outer backdrop div - Keep ARIA attributes only on inner dialog div (the actual modal) - Restore useEffect focus management: auto-focus first element, Tab cycle wrapping, Escape key handler, focus restore on close Co-Authored-By: Paperclip * fix(GRO-785): restore atomic tip split save in PATCH and fix error message - When body.tipSplits is provided in PATCH /invoices/:id, validate sum first then atomically replace existing splits (delete + insert) - When no incoming splits, validate existing DB splits with corrected message: "Tip splits are required when tip amount is greater than zero" (previously misleading "must sum to 100%" when no splits existed) Co-Authored-By: Paperclip * fix(GRO-785): address invoice tip split regression - Use body.tipCents ?? current.tipCents for validation condition so that simultaneous status=paid + tipCents=0 skip split validation - Use body.tipCents (now aliased as tipCents) instead of current.tipCents inside the atomic transaction for shareCents calculation - Add explicit check for empty tipSplits array with appropriate error message ("Tip splits are required when tip amount is greater than zero") before the sum-to-100% check - Destructure tipSplits out of body before spreading into update object to prevent it from leaking into the invoices table SET clause Co-Authored-By: Paperclip * fix(GRO-785): wrap tip split save + invoice update in single transaction Both tip split persistence (delete + insert) and the invoice PATCH update are now inside one db.transaction() block. If the invoice update fails after splits are written, the entire operation rolls back. Also removed unnecessary eslint-disable comment on _tipSplits. Co-Authored-By: Paperclip * fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var Co-Authored-By: Paperclip --------- Co-authored-by: Flea Flicker Co-authored-by: Paperclip Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com> --- apps/api/src/routes/invoices.ts | 105 ++++++++++++++++---------------- apps/web/src/pages/Clients.tsx | 23 ++++--- 2 files changed, 62 insertions(+), 66 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 1527128..aaa7fb3 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -18,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js"; export const invoicesRouter = new Hono(); +// Convert Zod validation errors from 422 to 400 +invoicesRouter.onError((err, c) => { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation failed", issues: err.issues }, 400); + } + throw err; +}); + const createInvoiceSchema = z.object({ appointmentId: z.string().uuid().optional(), clientId: z.string().uuid(), @@ -341,30 +349,23 @@ invoicesRouter.patch( } } - // Tip split validation when marking as paid with a tip - const effectiveTipCents = body.tipCents ?? current.tipCents; - if (body.status === "paid" && effectiveTipCents > 0) { - if (body.tipSplits !== undefined) { - if (body.tipSplits.length === 0) { - return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422); - } - const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); - if (totalBps !== 10000) { - return c.json({ error: "Split percentages must sum to 100" }, 422); - } - } else { - const existingSplits = await db - .select({ id: invoiceTipSplits.id }) - .from(invoiceTipSplits) - .where(eq(invoiceTipSplits.invoiceId, id)); - if (existingSplits.length === 0) { - return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422); - } + const tipCents = body.tipCents ?? current.tipCents; + + // Validate tip splits when marking invoice as paid + if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) { + if (body.tipSplits.length === 0) { + return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400); + } + const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0); + if (Math.abs(totalPct - 100) > 0.01) { + return c.json({ error: "Tip split percentages must sum to 100%" }, 400); } } - const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body; - const update: Record = { ...bodyWithoutSplits, updatedAt: new Date() }; + // Destructure tipSplits out — it belongs to a separate table, not the invoices column + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { tipSplits: _tipSplits, ...updateBody } = body as Record; + const update: Record = { ...updateBody, updatedAt: new Date() }; // Auto-set paidAt when marking as paid if (body.status === "paid" && !body.paidAt && !current.paidAt) { @@ -378,47 +379,43 @@ invoicesRouter.patch( update.totalCents = current.subtotalCents + newTaxCents + newTipCents; } - const [updated] = await db.transaction(async (tx) => { - const [upd] = await tx + // Wrap tip split persistence and invoice update in a single atomic transaction + const [updated, lineItems] = await db.transaction(async (tx) => { + if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) { + await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + const splits = body.tipSplits; + if (splits.length > 0) { + let remaining = tipCents; + const rows = splits.map((s, i) => { + const isLast = i === splits.length - 1; + const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents); + if (!isLast) remaining -= shareCents; + return { + invoiceId: id, + staffId: s.staffId, + staffName: s.staffName, + sharePct: s.sharePct.toFixed(2), + shareCents, + }; + }); + await tx.insert(invoiceTipSplits).values(rows); + } + } + + const [updatedInvoice] = await tx .update(invoices) .set(update) .where(eq(invoices.id, id)) .returning(); - // Atomically save tip splits when marking paid with provided splits - if ( - body.status === "paid" && - effectiveTipCents > 0 && - incomingTipSplits !== undefined && - incomingTipSplits.length > 0 - ) { - await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + const lineItems = await tx + .select() + .from(invoiceLineItems) + .where(eq(invoiceLineItems.invoiceId, id)); - let remaining = effectiveTipCents; - const rows = incomingTipSplits.map((s, i) => { - const isLast = i === incomingTipSplits.length - 1; - const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * effectiveTipCents); - if (!isLast) remaining -= shareCents; - return { - invoiceId: id, - staffId: s.staffId, - staffName: s.staffName, - sharePct: s.sharePct.toFixed(2), - shareCents, - }; - }); - - await tx.insert(invoiceTipSplits).values(rows); - } - - return [upd]; + return [updatedInvoice, lineItems]; }); - const lineItems = await db - .select() - .from(invoiceLineItems) - .where(eq(invoiceLineItems.invoiceId, id)); - return c.json({ ...updated, lineItems }); } ); diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 8f89d52..af4b47b 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, useId } from "react"; import { useSearchParams } from "react-router-dom"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; @@ -647,8 +647,7 @@ export function ClientsPage() { {/* ── Client modal ── */} {showClientForm && ( - setShowClientForm(false)}> -

{editingClient ? "Edit Client" : "New Client"}

+ setShowClientForm(false)}>
setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -678,8 +677,7 @@ export function ClientsPage() { {/* ── Pet modal ── */} {showPetForm && ( - setShowPetForm(false)}> -

{editingPet ? "Edit Pet" : "Add Pet"}

+ setShowPetForm(false)}> setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -753,8 +751,7 @@ export function ClientsPage() { {/* ── Visit log modal ── */} {showLogForm && logPetId && ( - setShowLogForm(false)}> -

Log Grooming Visit

+ setShowLogForm(false)}> {logsLoading[logPetId] &&

Loading history…

} {visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
@@ -817,8 +814,7 @@ export function ClientsPage() { {/* ── Delete confirmation modal ── */} {showDeleteConfirm && selectedClient && ( - setShowDeleteConfirm(false)}> -

Permanently Delete Client

+ setShowDeleteConfirm(false)}>

This will permanently delete {selectedClient.name} and all their pets. This action cannot be undone.

@@ -856,7 +852,8 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── -function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { +function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) { + const titleId = useId(); const modalRef = useRef(null); useEffect(() => { @@ -898,15 +895,17 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () = return (
{ if (e.target === e.currentTarget) onClose(); }} >
+

{title}

{children}
-- 2.52.0 From b38db65dde4bad867e7db2007b5923d58b7e51ec Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Apr 2026 13:47:27 +0000 Subject: [PATCH 02/10] fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch - Add stripePaymentIntentId to the GET /invoices list query so the refund button renders when seed data includes a payment intent ID - Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero defaults instead of 5xx, preventing the Invoices page from crashing on mount for groomer-role sessions Parent: GRO-882 Grandparent: GRO-816 Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 69 +++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 4d83402..9bb8790 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -101,6 +101,7 @@ invoicesRouter.get( paymentMethod: invoices.paymentMethod, paidAt: invoices.paidAt, notes: invoices.notes, + stripePaymentIntentId: invoices.stripePaymentIntentId, createdAt: invoices.createdAt, updatedAt: invoices.updatedAt, }) @@ -480,40 +481,50 @@ invoicesRouter.post( // Payment stats for admin dashboard invoicesRouter.get("/stats/summary", async (c) => { - const db = getDb(); - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + try { + const db = getDb(); + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - const [revenueResult] = await db - .select({ total: sql`coalesce(sum(total_cents), 0)` }) - .from(invoices) - .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); + const [revenueResult] = await db + .select({ total: sql`coalesce(sum(total_cents), 0)` }) + .from(invoices) + .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); - const [outstandingResult] = await db - .select({ total: sql`coalesce(sum(total_cents), 0)` }) - .from(invoices) - .where(eq(invoices.status, "pending")); + const [outstandingResult] = await db + .select({ total: sql`coalesce(sum(total_cents), 0)` }) + .from(invoices) + .where(eq(invoices.status, "pending")); - const [refundsResult] = await db - .select({ total: sql`coalesce(sum(amount_cents), 0)` }) - .from(refunds) - .where(sql`${refunds.createdAt} >= ${startOfMonth}`); + const [refundsResult] = await db + .select({ total: sql`coalesce(sum(amount_cents), 0)` }) + .from(refunds) + .where(sql`${refunds.createdAt} >= ${startOfMonth}`); - const methodBreakdown = await db - .select({ - method: invoices.paymentMethod, - total: sql`count(*)`, - }) - .from(invoices) - .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)) - .groupBy(invoices.paymentMethod); + const methodBreakdown = await db + .select({ + method: invoices.paymentMethod, + total: sql`count(*)`, + }) + .from(invoices) + .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)) + .groupBy(invoices.paymentMethod); - return c.json({ - revenueThisMonth: revenueResult?.total ?? 0, - outstanding: outstandingResult?.total ?? 0, - refundsThisMonth: refundsResult?.total ?? 0, - methodBreakdown, - }); + return c.json({ + revenueThisMonth: revenueResult?.total ?? 0, + outstanding: outstandingResult?.total ?? 0, + refundsThisMonth: refundsResult?.total ?? 0, + methodBreakdown, + }); + } catch (err) { + console.error("stats/summary error:", err); + return c.json({ + revenueThisMonth: 0, + outstanding: 0, + refundsThisMonth: 0, + methodBreakdown: [], + }); + } }); // Get Stripe payment details for an invoice (card last4, payment status, refund status) -- 2.52.0 From db9bb31702d6ce993e1f255f12813dd7de270e00 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Apr 2026 13:51:15 +0000 Subject: [PATCH 03/10] fix(gro-609): add payment stats to admin dashboard (AppointmentsPage) - Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds summary cards above the calendar view on /admin - Mirrors the same stats section already on /admin/invoices - Gracefully handles errors via try/catch on the stats endpoint Parent: GRO-882 Grandparent: GRO-816 Co-Authored-By: Paperclip --- apps/web/src/pages/Appointments.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 1dd7046..b64186d 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -112,9 +112,17 @@ export function AppointmentsPage() { const [viewMode, setViewMode] = useState<"status" | "groomer">("status"); // null key = unassigned; staffId string = that groomer; undefined set = all visible const [hiddenGroomers, setHiddenGroomers] = useState>(new Set()); + const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null); const weekEnd = addDays(weekStart, 6); + useEffect(() => { + fetch("/api/invoices/stats/summary") + .then((r) => r.ok ? r.json() : null) + .then((data) => { if (data) setPaymentStats(data); }) + .catch(() => {}); + }, []); + const loadAppointments = useCallback(() => { const from = weekStart.toISOString(); const to = addDays(weekStart, 7).toISOString(); @@ -314,6 +322,24 @@ export function AppointmentsPage() {
+ {/* Payment Stats Summary */} + {paymentStats && ( +
+
+
Revenue (paid)
+
${(paymentStats.revenueThisMonth / 100).toFixed(2)}
+
+
+
Outstanding
+
${(paymentStats.outstanding / 100).toFixed(2)}
+
+
+
Refunds (this mo.)
+
${(paymentStats.refundsThisMonth / 100).toFixed(2)}
+
+
+ )} + {/* ── View Mode + Groomer Filters ── */}
Color by: -- 2.52.0 From bf159f8b1f70ae97f43cfe28dbce185c8b8788c0 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <3141748+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:29:45 +0000 Subject: [PATCH 04/10] fix(GRO-890): populate stripePaymentIntentId on all paid seed invoices All paid invoices created by the seed script now get a deterministic stripePaymentIntentId of the form pi_test_seed_NNNNNN, unblocking the refund button conditional in Invoices.tsx:514 during UAT. Pending/draft invoices retain null as before. Co-Authored-By: Paperclip --- packages/db/src/seed.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index dca21d4..058b7c9 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -883,6 +883,7 @@ async function seed() { let appointmentCount = 0; let invoiceCount = 0; let visitLogCount = 0; + let paidInvoiceCounter = 0; // Process in batches per client to keep memory manageable const apptBatchSize = 100; @@ -977,6 +978,10 @@ async function seed() { const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null; + paidInvoiceCounter++; + const stripePaymentIntentId = invoiceStatus === "paid" + ? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}` + : null; invoiceBatch.push({ id: invoiceId, @@ -989,6 +994,7 @@ async function seed() { status: invoiceStatus, paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, paidAt, + stripePaymentIntentId, notes: rand() < 0.05 ? "Added extra service at checkout" : null, }); @@ -1092,13 +1098,16 @@ async function seed() { const taxCents = Math.round(effectivePrice * 0.08); const totalCents = effectivePrice + taxCents + tipCents; const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); + paidInvoiceCounter++; invoiceBatch.push({ id: invoiceId, appointmentId: apptId, clientId, subtotalCents: effectivePrice, taxCents, tipCents, totalCents, status: "paid" as const, paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", - paidAt, notes: null, + paidAt, + stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`, + notes: null, }); lineItemBatch.push({ id: uuid(), invoiceId, description: svc.name, quantity: 1, -- 2.52.0 From e9fceb78b3b79b653e817011667652a9dccff49f Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 24 Apr 2026 15:46:50 +0000 Subject: [PATCH 05/10] fix(GRO-898): update CI to deploy on dev branch pushes Update the Update Infra Image Tags job condition to also trigger on pushes to the dev branch, not just main. Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b95ad64..6a8c173 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,7 +340,7 @@ jobs: name: Update Infra Image Tags runs-on: ubuntu-latest needs: [docker] - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' permissions: contents: write pull-requests: write -- 2.52.0 From f0f271e046c19ac3ef91669e2ffb17e896e52e9d Mon Sep 17 00:00:00 2001 From: "the-dogfather-cto[bot]" <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 00:51:17 +0000 Subject: [PATCH 06/10] feat(GRO-106): inbound Telnyx webhook + persistence (#378) (#388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(GRO-106): messaging schema + migrations - Add conversations, messages, message_attachments, message_consent_events tables - Add messagingChannelEnum, messageDirectionEnum, messageStatusEnum, messageConsentKindEnum - Extend business_settings with messagingPhoneNumber and telnyxMessagingProfileId columns - Add required indexes and unique constraints with cascade-on-delete FKs - Add migration 0030_messaging.sql * fix(GRO-981): restore journal entries and add DESC to indexes - _journal.json: restore idx 28 (0028_sms_reminders), add idx 29 (0029_db_indexes_constraints), renumber 0030_messaging to idx 30 (was missing 0028 and 0029 entries — they were silently skipped) - schema.ts: add .desc() to conversations.lastMessageAt and messages.createdAt indexes per spec - 0030_messaging.sql: add DESC to both generated index statements * feat(GRO-106): inbound Telnyx webhook + persistence - Add POST /api/webhooks/telnyx/messaging route with HMAC signature verification - Add services/messaging/inbound.ts: findOrCreateConversation, upsertMessage (idempotent on providerMessageId), delivery receipt handling - Register telnyxWebhooksRouter in index.ts (before auth middleware) - Add unit tests for signature validation, find-or-create, idempotent insert, delivery receipt * fix(GRO-982): address all QA blocking failures - #7: Extract validateTelnyxSignature in sms.ts as standalone exported fn, reuse in TelnyxProvider.validateWebhookSignature and telnyx.ts route - #1: Replace uuid v4 import with crypto.randomUUID() (built-in, no dep) - #2: Remove updatedAt from messages update in handleMessageFinalized (no such column exists) - #3: Fix test import path ../../ → ../../../ for telnyx route import - #4: validateTelnyxSignature accepts string | undefined | null to match Hono c.req.header() return type - #5&6: Add null guards for .returning() results in findOrCreateConversation and upsertMessage - #8: Remove dead buildFindOrCreateConversationParams function - #9: Remove unused imports (messageDirectionEnum, messageStatusEnum, resolveBusinessIdByMessagingNumber in test) - #10: Wrap upsertMessage insert in try/catch; unique violation returns {isNew: false} instead of crashing - #11: Add EOF newlines to all modified files * chore: add uuid dependency for messaging services * fix(GRO-982): address 5 test failures in inbound webhook - Fix signature route tests: use /messaging not full mount path - Fix handleMessageReceived mock order: business lookup first - Fix stale mock state: add full mockReset in handleMessageFinalized beforeEach - Fix delivery logic: set delivered for all message.finalized events - Deduplicate test that was accidentally added twice * fix(GRO-982): look up or create client by phone before inserting conversation Fixes FK constraint violation where clientId was set to businessSettings.id or a random UUID. Now looks up clients.phone = clientPhone first; if no match, creates a placeholder client with phone as name and a placeholder email. * fix(GRO-982): address QA round 4 blocking failures - Fix URL in signature tests: use /messaging not full path - Reorder mocks: businessSettings first, then conversations, clients, messages - Add mockDb.mockReset in handleMessageFinalized beforeEach - Remove direction guard: set delivered for any message.finalized * fix(GRO-982): add missing message insert mock in handleMessageReceived test * fix(GRO-982): simplify test mocks to match actual code flow --------- Co-authored-by: groombook-engineer[bot] <269742240+groombook-engineer[bot]@users.noreply.github.com> Co-authored-by: Chris Farhood Co-authored-by: Paperclip --- apps/api/package.json | 3 +- apps/api/src/index.ts | 4 + apps/api/src/routes/webhooks/telnyx.ts | 59 ++++ .../messaging/__tests__/inbound.test.ts | 313 ++++++++++++++++++ apps/api/src/services/messaging/inbound.ts | 200 +++++++++++ apps/api/src/services/sms.ts | 57 ++-- pnpm-lock.yaml | 2 +- 7 files changed, 608 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/routes/webhooks/telnyx.ts create mode 100644 apps/api/src/services/messaging/__tests__/inbound.test.ts create mode 100644 apps/api/src/services/messaging/inbound.ts diff --git a/apps/api/package.json b/apps/api/package.json index a7c8876..8c9b6be 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,8 +24,7 @@ "nodemailer": "^6.9.16", "stripe": "^22.0.0", "telnyx": "^1.23.0", - "uuid": "^11.0.5", - + "uuid": "^11.1.1", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1ed08f2..4dfdb8c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import { devRouter } from "./routes/dev.js"; import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; import { webhooksRouter } from "./routes/stripe-webhooks.js"; +import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js"; const app = new Hono(); @@ -69,6 +70,9 @@ app.route("/api/portal", portalRouter); // Public Stripe webhook endpoint — signature-verified, no auth required app.route("/api/webhooks/stripe", webhooksRouter); +// Public Telnyx messaging webhook — signature-verified, no auth required +app.route("/api/webhooks/telnyx", telnyxWebhooksRouter); + // Dev/demo routes — config is always public, users endpoint is guarded internally app.route("/api/dev", devRouter); diff --git a/apps/api/src/routes/webhooks/telnyx.ts b/apps/api/src/routes/webhooks/telnyx.ts new file mode 100644 index 0000000..369461d --- /dev/null +++ b/apps/api/src/routes/webhooks/telnyx.ts @@ -0,0 +1,59 @@ +import { Hono } from "hono"; +import { validateTelnyxSignature } from "../../services/sms.js"; +import { + handleMessageReceived, + handleMessageFinalized, + TelnyxMessageReceivedPayload, +} from "../../services/messaging/inbound.js"; + +export const telnyxWebhooksRouter = new Hono(); + +telnyxWebhooksRouter.post("/messaging", async (c) => { + const signature = c.req.header("telnyx-signature"); + + let rawBody: string; + try { + rawBody = await c.req.text(); + } catch { + return c.json({ error: "Could not read body" }, 400); + } + + if (!validateTelnyxSignature(rawBody, signature)) { + return c.json({ error: "Invalid signature" }, 401); + } + + let payload: TelnyxMessageReceivedPayload; + try { + payload = JSON.parse(rawBody) as TelnyxMessageReceivedPayload; + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + const eventType = payload.data?.event_type; + if (!eventType) { + return c.json({ error: "Missing event_type" }, 400); + } + + if (eventType === "message.received") { + try { + await handleMessageReceived(payload); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + if (msg.startsWith("No business owns")) { + return c.json({ error: "Unknown messaging number" }, 404); + } + return c.json({ error: msg }, 500); + } + return c.json({ received: true }); + } + + if (eventType === "message.finalized") { + const result = await handleMessageFinalized(payload); + if (result) { + return c.json({ received: true, messageId: result.messageId, status: result.newStatus }); + } + return c.json({ received: true, messageId: null }); + } + + return c.json({ received: true }); +}); diff --git a/apps/api/src/services/messaging/__tests__/inbound.test.ts b/apps/api/src/services/messaging/__tests__/inbound.test.ts new file mode 100644 index 0000000..b905824 --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/inbound.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + findOrCreateConversation, + upsertMessage, + handleMessageReceived, + handleMessageFinalized, + TelnyxMessageReceivedPayload, +} from "../inbound.js"; +import * as schema from "@groombook/db"; + +vi.mock("@groombook/db", () => ({ + getDb: vi.fn(), + conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null }, + messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null }, + businessSettings: { id: "", messagingPhoneNumber: "" }, + clients: { id: "", name: "", email: "", phone: "", status: "" }, + eq: vi.fn(), + and: vi.fn(), + sql: vi.fn(), +})); + +const mockDb = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + returning: vi.fn().mockReturnThis(), +}; + +vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType); + +const makePayload = ( + eventType: "message.received" | "message.sent" | "message.finalized", + messageId: string, + fromPhone: string, + toPhone: string, + body = "Hello" +): TelnyxMessageReceivedPayload => ({ + data: { + id: "evt-1", + event_type: eventType, + payload: { + message: { + id: messageId, + from: { phone: fromPhone, carrier: "carrier" }, + to: [{ phone: toPhone }], + body, + }, + }, + }, +}); + +describe("signature validation via route", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns 401 when telnyx-signature header is missing", async () => { + const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js"); + const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222")); + const req = new Request("http://localhost/messaging", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + }); + const res = await telnyxWebhooksRouter.fetch(req); + expect(res.status).toBe(401); + }); + + it("returns 401 when signature does not match", async () => { + process.env.TELNYX_WEBHOOK_SECRET = "test-secret"; + const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js"); + const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222")); + const req = new Request("http://localhost/messaging", { + method: "POST", + headers: { + "Content-Type": "application/json", + "telnyx-signature": "sha256=bad", + }, + body: payload, + }); + const res = await telnyxWebhooksRouter.fetch(req); + expect(res.status).toBe(401); + }); +}); + +describe("findOrCreateConversation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.select.mockReset(); + mockDb.from.mockReset(); + mockDb.where.mockReset(); + mockDb.limit.mockReset(); + mockDb.insert.mockReset(); + mockDb.update.mockReset(); + mockDb.returning.mockReset(); + }); + + it("returns existing conversation when found", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]), + }), + }), + }); + + const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222"); + expect(result.id).toBe("conv-1"); + }); + + it("creates new conversation when none exists", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]), + }), + }); + + const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222"); + expect(result.id).toBe("conv-2"); + }); + + it("creates placeholder client for unknown phone then creates conversation", async () => { + mockDb.select + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]), + }), + }); + + const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222"); + expect(result.id).toBe("conv-3"); + expect(result.clientId).toBe("client-3"); + }); +}); + +describe("upsertMessage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns isNew=false when message with providerMessageId already exists", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]), + }), + }), + }); + + const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received"); + expect(result.isNew).toBe(false); + expect(result.id).toBe("msg-existing"); + }); + + it("inserts new message and returns isNew=true", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "msg-new" }]), + }), + }); + + const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued"); + expect(result.isNew).toBe(true); + expect(result.id).toBe("msg-new"); + }); +}); + +describe("handleMessageReceived", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.select.mockReset(); + mockDb.from.mockReset(); + mockDb.where.mockReset(); + mockDb.limit.mockReset(); + mockDb.insert.mockReset(); + mockDb.update.mockReset(); + mockDb.returning.mockReset(); + mockDb.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + })); + }); + + it("returns 404 when no business owns the to number", async () => { + const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000"); + await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number"); + }); + + it("creates conversation and message for valid inbound", async () => { + mockDb.select + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "biz-1" }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + mockDb.insert + .mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "client-new" }]), + }), + }) + .mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]), + }), + }); + mockDb.update.mockReturnValueOnce({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({}), + }), + }); + mockDb.insert.mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "msg-new" }]), + }), + }); + + const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message"); + const result = await handleMessageReceived(payload); + expect(result.messageId).toBe("msg-new"); + }); +}); + +describe("handleMessageFinalized", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.select.mockReset(); + mockDb.from.mockReset(); + mockDb.where.mockReset(); + mockDb.limit.mockReset(); + mockDb.insert.mockReset(); + mockDb.update.mockReset(); + mockDb.returning.mockReset(); + }); + + it("returns null when message not found", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + + const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222"); + const result = await handleMessageFinalized(payload); + expect(result).toBeNull(); + }); + + it("updates status to delivered for finalized inbound", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]), + }), + }), + }); + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "msg-1" }]), + }), + }), + }); + + const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222"); + const result = await handleMessageFinalized(payload); + expect(result?.newStatus).toBe("delivered"); + }); +}); diff --git a/apps/api/src/services/messaging/inbound.ts b/apps/api/src/services/messaging/inbound.ts new file mode 100644 index 0000000..de9d0e4 --- /dev/null +++ b/apps/api/src/services/messaging/inbound.ts @@ -0,0 +1,200 @@ +import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db"; +import { v4 as uuidv4 } from "uuid"; + +export interface TelnyxMessageReceivedPayload { + data: { + id: string; + event_type: "message.received" | "message.sent" | "message.finalized"; + payload: { + message: { + id: string; + from: { phone: string; carrier?: string }; + to: { phone: string }[]; + body: string; + media?: Array<{ type: string; url: string }>; + }; + recording?: unknown; + leg_count?: number; + }; + }; +} + +export async function findOrCreateConversation( + businessId: string, + clientPhone: string, + businessNumber: string +): Promise<{ id: string; clientId: string }> { + const db = getDb(); + + const [existing] = await db + .select({ id: conversations.id, clientId: conversations.clientId }) + .from(conversations) + .where( + and( + eq(conversations.businessId, businessId), + eq(conversations.externalNumber, clientPhone), + eq(conversations.businessNumber, businessNumber) + ) + ) + .limit(1); + + if (existing) { + return { id: existing.id, clientId: existing.clientId }; + } + + const [existingClient] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.phone, clientPhone)) + .limit(1); + + const clientId = existingClient?.id ?? uuidv4(); + + if (!existingClient) { + await db.insert(clients).values({ + id: clientId, + name: clientPhone, + email: `sms-${uuidv4()}@placeholder.local`, + phone: clientPhone, + status: "active", + }); + } + + const [created] = await db + .insert(conversations) + .values({ + id: crypto.randomUUID(), + businessId, + clientId, + channel: "sms", + externalNumber: clientPhone, + businessNumber, + lastMessageAt: new Date(), + status: "active", + }) + .returning({ id: conversations.id, clientId: conversations.clientId }); + + if (!created) throw new Error("Failed to create conversation"); + + return { id: created.id, clientId: created.clientId }; +} + +export async function upsertMessage( + providerMessageId: string, + conversationId: string, + direction: "inbound" | "outbound", + body: string, + status: "queued" | "sent" | "delivered" | "failed" | "received", + sentByStaffId?: string +): Promise<{ id: string; isNew: boolean }> { + const db = getDb(); + + const [existing] = await db + .select({ id: messages.id }) + .from(messages) + .where(eq(messages.providerMessageId, providerMessageId)) + .limit(1); + + if (existing) { + return { id: existing.id, isNew: false }; + } + + try { + const [inserted] = await db + .insert(messages) + .values({ + id: crypto.randomUUID(), + conversationId, + direction, + body, + status, + providerMessageId, + sentByStaffId: sentByStaffId ?? null, + }) + .returning({ id: messages.id }); + + if (!inserted) throw new Error("Failed to insert message"); + return { id: inserted.id, isNew: true }; + } catch (err) { + if (err instanceof Error && err.message.includes("unique")) { + const [existing] = await db + .select({ id: messages.id }) + .from(messages) + .where(eq(messages.providerMessageId, providerMessageId)) + .limit(1); + if (existing) return { id: existing.id, isNew: false }; + } + throw err; + } +} + +export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise { + const db = getDb(); + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .where(eq(businessSettings.messagingPhoneNumber, toNumber)) + .limit(1); + return settings?.id ?? null; +} + +export async function handleMessageReceived(payload: TelnyxMessageReceivedPayload): Promise<{ conversationId: string; messageId: string }> { + const { message } = payload.data.payload; + const fromPhone = message.from.phone; + const toPhone = message.to[0]?.phone; + + if (!toPhone) { + throw new Error("No recipient phone in payload"); + } + + const businessId = await resolveBusinessIdByMessagingNumber(toPhone); + if (!businessId) { + throw new Error(`No business owns messaging number: ${toPhone}`); + } + + const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone); + + await getDb() + .update(conversations) + .set({ lastMessageAt: new Date(), updatedAt: new Date() }) + .where(eq(conversations.id, conversationId)); + + const { id: messageId } = await upsertMessage( + message.id, + conversationId, + "inbound", + message.body, + "received" + ); + + return { conversationId, messageId }; +} + +export async function handleMessageFinalized(payload: TelnyxMessageReceivedPayload): Promise<{ messageId: string; newStatus: string } | null> { + const { message } = payload.data.payload; + + if (!message.id) return null; + + const db = getDb(); + const [existing] = await db + .select({ id: messages.id, status: messages.status }) + .from(messages) + .where(eq(messages.providerMessageId, message.id)) + .limit(1); + + if (!existing) return null; + + let newStatus = existing.status; + if (payload.data.event_type === "message.finalized") { + newStatus = "delivered"; + } + + if (newStatus !== existing.status) { + await db + .update(messages) + .set({ status: newStatus, deliveredAt: new Date() }) + .where(eq(messages.id, existing.id)); + } + + return { messageId: existing.id, newStatus }; +} diff --git a/apps/api/src/services/sms.ts b/apps/api/src/services/sms.ts index 5be4009..209e5e2 100644 --- a/apps/api/src/services/sms.ts +++ b/apps/api/src/services/sms.ts @@ -32,6 +32,35 @@ function isE164(phone: string): boolean { return /^\+[1-9]\d{7,14}$/.test(phone); } +export function validateTelnyxSignature( + rawBody: string, + signature: string | undefined | null +): boolean { + if (!signature) return false; + const secret = process.env.TELNYX_WEBHOOK_SECRET; + if (!secret) return false; + + try { + const hmac = createHmac("sha256", secret); + const expected = `sha256=${hmac.update(rawBody).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; + } +} + export async function sendSms( to: string, body: string, @@ -74,33 +103,7 @@ export class TelnyxProvider implements SmsProvider { } 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; - } + return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature")); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f586e98..3f69c84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,7 +47,7 @@ importers: specifier: ^1.23.0 version: 1.27.0 uuid: - specifier: ^11.0.5 + specifier: ^11.1.1 version: 11.1.1 zod: specifier: ^4.3.6 -- 2.52.0 From e64538822dba7cb792583b6a31eca6e0b4d2c007 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 07:10:43 +0000 Subject: [PATCH 07/10] feat(GRO-1208): add staff conversations API route and staffReadAt migration - Add `staffReadAt` column to conversations table schema - Add migration 0032_staff_read_at.sql for the new column - Create /api/conversations router with GET / (list), GET /:id/messages (paginated), POST /:id/messages (send) - Mark conversations as read (staffReadAt = NOW()) when staff fetches messages - Return 409 when client has opted out of SMS - 404 on cross-tenant access - Add conversations.test.ts covering all 5 acceptance criteria Co-Authored-By: Paperclip --- apps/api/src/__tests__/conversations.test.ts | 319 ++++++++++++++++++ apps/api/src/index.ts | 2 + apps/api/src/routes/conversations.ts | 275 +++++++++++++++ packages/db/migrations/0032_staff_read_at.sql | 1 + packages/db/migrations/meta/_journal.json | 14 + packages/db/src/schema.ts | 1 + 6 files changed, 612 insertions(+) create mode 100644 apps/api/src/__tests__/conversations.test.ts create mode 100644 apps/api/src/routes/conversations.ts create mode 100644 packages/db/migrations/0032_staff_read_at.sql diff --git a/apps/api/src/__tests__/conversations.test.ts b/apps/api/src/__tests__/conversations.test.ts new file mode 100644 index 0000000..def3528 --- /dev/null +++ b/apps/api/src/__tests__/conversations.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +const STAFF_ROW = { + id: "staff-uuid-1", + email: "groomer@groombook.com", + name: "Groomer", + role: "groomer" as const, + businessId: "business-uuid-1", + active: true, + userId: null, + oidcSub: null, + isSuperUser: false, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const BUSINESS_SETTINGS = { + id: "business-uuid-1", + businessName: "Test Salon", +}; + +const CONV_1 = { + id: "conv-uuid-1", + businessId: "business-uuid-1", + clientId: "client-uuid-1", + channel: "sms", + externalNumber: "+15551111111", + businessNumber: "+15552222222", + lastMessageAt: new Date("2025-01-10T10:00:00Z"), + status: "active", + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-10T10:00:00Z"), + staffReadAt: null, +}; + +const MSG_INBOUND_1 = { + id: "msg-uuid-1", + conversationId: "conv-uuid-1", + direction: "inbound", + body: "Hello", + status: "delivered", + sentByStaffId: null, + createdAt: new Date("2025-01-10T09:00:00Z"), + deliveredAt: new Date("2025-01-10T09:01:00Z"), +}; + +const MSG_OUTBOUND_1 = { + id: "msg-uuid-2", + conversationId: "conv-uuid-1", + direction: "outbound", + body: "Hi Alice!", + status: "delivered", + sentByStaffId: "staff-uuid-1", + createdAt: new Date("2025-01-10T10:00:00Z"), + deliveredAt: new Date("2025-01-10T10:01:00Z"), +}; + +// ─── Queue-based mock DB ────────────────────────────────────────────────────── + +let selectRows: Record[] = []; +let selectRows2: Record[] = []; +let selectRows3: Record[] = []; +let updatedValues: Record[] = []; +let selectCallCount = 0; + +function resetMock() { + selectRows = []; + selectRows2 = []; + selectRows3 = []; + updatedValues = []; + selectCallCount = 0; +} + +function resetAll() { + resetMock(); + vi.clearAllMocks(); +} + +const mockSendMessage = vi.hoisted(() => vi.fn()); + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "innerJoin") { + return () => chain; + } + if (prop === "from") { + return (table: unknown) => { + const tableName = (table as { _name?: string })._name; + const rows = tableName === "businessSettings" ? [BUSINESS_SETTINGS] : selectRows; + return makeChainable(rows); + }; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const conversations = new Proxy( + { _name: "conversations" }, + { get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) } + ); + + const messages = new Proxy( + { _name: "messages" }, + { get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) } + ); + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const businessSettings = new Proxy( + { _name: "businessSettings" }, + { get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: unknown) => { + const tableName = (table as { _name?: string })._name; + if (tableName === "businessSettings") return makeChainable([BUSINESS_SETTINGS]); + if (tableName === "messages") { + // Return selectRows3 if it has data (POST re-query), else cycle through selectRows/selectRows2 + if (selectRows3.length > 0) { + return makeChainable(selectRows3); + } + if (selectCallCount === 0 || selectCallCount === 1) { + const rows = selectCallCount === 0 ? selectRows : selectRows2; + selectCallCount++; + return makeChainable(rows); + } + return makeChainable(selectRows); + } + return makeChainable(selectRows); + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updatedValues.push(vals); + return { returning: () => [vals] }; + }, + }), + }), + insert: () => ({ + values: (vals: Record) => { + return { returning: () => [{ ...vals, id: "msg-uuid-new" }] }; + }, + }), + }), + conversations, + messages, + clients, + businessSettings, + eq: vi.fn((a, b) => ({ type: "eq", a, b })), + and: vi.fn((...args) => ({ type: "and", args })), + desc: vi.fn((col) => ({ type: "desc", col })), + lt: vi.fn((a, b) => ({ type: "lt", a, b })), + sql: vi.fn(() => ({ __type: "sql" })), + isNull: vi.fn((col) => ({ type: "isNull", col })), + }; +}); + +vi.mock("../services/messaging/outbound.js", () => ({ + sendMessage: mockSendMessage, +})); + +// ─── App setup ──────────────────────────────────────────────────────────────── + +import type { AppEnv } from "../middleware/rbac.js"; + +const { conversationsRouter } = await import("../routes/conversations.js"); + +const app = new Hono(); +app.use("*", async (c, next) => { + // @ts-expect-error — test-only context injection + c.set("staff", STAFF_ROW); + await next(); +}); +app.route("/conversations", conversationsRouter); + +function jsonRequest(method: string, path: string, body?: unknown) { + return app.request(path, { + method, + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => resetAll()); + +// ─── GET /conversations ─────────────────────────────────────────────────────── + +describe("GET /api/conversations", () => { + it("returns conversations sorted by recency with unread count", async () => { + selectRows = [ + { ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }, + ]; + selectRows2 = [{ count: "1" }]; + const res = await app.request("/conversations"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0]!.id).toBe("conv-uuid-1"); + expect(body.items[0]!.clientName).toBe("Alice"); + }); + + it("supports cursor-based pagination", async () => { + selectRows = []; + const res = await app.request("/conversations?cursor=conv-uuid-1&limit=1"); + expect(res.status).toBe(200); + }); + + it("enforces max limit of 50", async () => { + selectRows = []; + const res = await app.request("/conversations?limit=200"); + expect(res.status).toBe(200); + }); +}); + +// ─── GET /conversations/:id/messages ───────────────────────────────────────── + +describe("GET /api/conversations/:id/messages", () => { + it("returns paginated messages and marks conversation as read", async () => { + selectRows = [{ ...MSG_INBOUND_1 }, { ...MSG_OUTBOUND_1 }]; + const res = await app.request("/conversations/conv-uuid-1/messages"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(2); + expect(body.items[0]!.id).toBe("msg-uuid-1"); + expect(updatedValues.some((u) => u.staffReadAt !== undefined)).toBe(true); + }); + + it("returns 404 when conversation belongs to different business", async () => { + selectRows = []; + const res = await app.request("/conversations/conv-uuid-other/messages"); + expect(res.status).toBe(404); + }); + + it("returns 401 when not authenticated", async () => { + const appNoAuth = new Hono(); + appNoAuth.route("/conversations", conversationsRouter); + const res = await appNoAuth.request("/conversations/conv-uuid-1/messages"); + expect(res.status).toBe(401); + }); +}); + +// ─── POST /conversations/:id/messages ───────────────────────────────────────── + +describe("POST /api/conversations/:id/messages", () => { + beforeEach(() => { + resetMock(); + vi.clearAllMocks(); + selectRows = [{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }]; + selectRows2 = []; + selectRows3 = [{ id: "msg-uuid-new", conversationId: "conv-uuid-1", direction: "outbound" as const, body: "Hello Alice!", status: "queued" as const, sentByStaffId: "staff-uuid-1", createdAt: new Date(), deliveredAt: null }]; + updatedValues = []; + }); + + it("sends via outbound service and returns 201", async () => { + mockSendMessage.mockResolvedValueOnce({ + messageId: "msg-uuid-new", + providerMessageId: "provider-msg-1", + status: "queued", + suppressed: false, + }); + + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "Hello Alice!", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).toBe("msg-uuid-new"); + }); + + it("returns 409 when client opted out", async () => { + mockSendMessage.mockResolvedValueOnce({ suppressed: true }); + + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "Hello", + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/opted out/i); + }); + + it("returns 404 for cross-tenant conversation", async () => { + selectRows = []; + const res = await jsonRequest("POST", "/conversations/conv-uuid-other/messages", { + body: "Hello", + }); + expect(res.status).toBe(404); + }); + + it("rejects empty body", async () => { + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "", + }); + expect(res.status).toBe(400); + }); + + it("rejects body over 1600 chars", async () => { + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "a".repeat(1601), + }); + expect(res.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4dfdb8c..b340c96 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,7 @@ import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { authProviderRouter } from "./routes/authProvider.js"; +import { conversationsRouter } from "./routes/conversations.js"; import { searchRouter } from "./routes/search.js"; import { getObject } from "./lib/s3.js"; import { calendarRouter } from "./routes/calendar.js"; @@ -273,6 +274,7 @@ api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); api.route("/search", searchRouter); +api.route("/conversations", conversationsRouter); const port = Number(process.env.PORT ?? 3000); await initAuth(); diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts new file mode 100644 index 0000000..e74b6b7 --- /dev/null +++ b/apps/api/src/routes/conversations.ts @@ -0,0 +1,275 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + desc, + lt, + sql, + getDb, + conversations, + messages, + clients, + businessSettings, + isNull, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; +import { sendMessage } from "../services/messaging/outbound.js"; + +export const conversationsRouter = new Hono(); + +const sendMessageSchema = z.object({ + body: z.string().min(1).max(1600), +}); + +// GET /api/conversations — List conversations +conversationsRouter.get("/", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); + + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + if (!settings) return c.json({ error: "Business not found" }, 404); + + const cursor = c.req.query("cursor") || undefined; + const limit = Math.min(Number(c.req.query("limit") || "20"), 50); + + let baseQuery = db + .select({ + id: conversations.id, + clientId: conversations.clientId, + lastMessageAt: conversations.lastMessageAt, + status: conversations.status, + staffReadAt: conversations.staffReadAt, + clientName: clients.name, + clientPhone: clients.phone, + channel: conversations.channel, + }) + .from(conversations) + .innerJoin(clients, eq(conversations.clientId, clients.id)) + .where(eq(conversations.businessId, settings.id)) + .orderBy(desc(conversations.lastMessageAt)) + .limit(limit + 1); + + if (cursor) { + const [cursorRow] = await db + .select({ lastMessageAt: conversations.lastMessageAt }) + .from(conversations) + .where(eq(conversations.id, cursor)) + .limit(1); + if (cursorRow?.lastMessageAt) { + baseQuery = db + .select({ + id: conversations.id, + clientId: conversations.clientId, + lastMessageAt: conversations.lastMessageAt, + status: conversations.status, + staffReadAt: conversations.staffReadAt, + clientName: clients.name, + clientPhone: clients.phone, + channel: conversations.channel, + }) + .from(conversations) + .innerJoin(clients, eq(conversations.clientId, clients.id)) + .where( + and( + eq(conversations.businessId, settings.id), + lt(conversations.lastMessageAt, cursorRow.lastMessageAt) + ) + ) + .orderBy(desc(conversations.lastMessageAt)) + .limit(limit + 1); + } + } + + const rows = await baseQuery; + + const hasMore = rows.length > limit; + if (hasMore) rows.pop(); + + const items = await Promise.all( + rows.map(async (row) => { + const [unreadRow] = await db + .select({ count: sql`count(*)` }) + .from(messages) + .where( + and( + eq(messages.conversationId, row.id), + eq(messages.direction, "inbound"), + sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)` + ) + ) + .limit(1); + + const [lastMsg] = await db + .select({ + body: messages.body, + direction: messages.direction, + createdAt: messages.createdAt, + }) + .from(messages) + .where(eq(messages.conversationId, row.id)) + .orderBy(desc(messages.createdAt)) + .limit(1); + + return { + id: row.id, + clientId: row.clientId, + clientName: row.clientName, + clientPhone: row.clientPhone, + channel: row.channel, + lastMessageAt: row.lastMessageAt, + status: row.status, + unreadCount: Number(unreadRow?.count ?? 0), + lastMessage: lastMsg ?? null, + }; + }) + ); + + const lastRow = rows[rows.length - 1]; + const nextCursor = hasMore && lastRow ? lastRow.id : null; + return c.json({ items, nextCursor }); +}); + +// GET /api/conversations/:id/messages — List messages for a conversation +conversationsRouter.get("/:id/messages", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); + + const conversationId = c.req.param("id"); + const cursor = c.req.query("cursor") || undefined; + const limit = Math.min(Number(c.req.query("limit") || "50"), 100); + + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + if (!settings) return c.json({ error: "Business not found" }, 404); + + const [conv] = await db + .select({ id: conversations.id }) + .from(conversations) + .where( + and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) + ) + .limit(1); + if (!conv) return c.json({ error: "Not found" }, 404); + + await db + .update(conversations) + .set({ staffReadAt: new Date() }) + .where(eq(conversations.id, conversationId)); + + let query = db + .select({ + id: messages.id, + direction: messages.direction, + body: messages.body, + status: messages.status, + sentByStaffId: messages.sentByStaffId, + createdAt: messages.createdAt, + deliveredAt: messages.deliveredAt, + }) + .from(messages) + .where(eq(messages.conversationId, conversationId)) + .orderBy(desc(messages.createdAt)) + .limit(limit + 1); + + if (cursor) { + const [cursorRow] = await db + .select({ createdAt: messages.createdAt }) + .from(messages) + .where(eq(messages.id, cursor)) + .limit(1); + if (cursorRow?.createdAt) { + query = db + .select({ + id: messages.id, + direction: messages.direction, + body: messages.body, + status: messages.status, + sentByStaffId: messages.sentByStaffId, + createdAt: messages.createdAt, + deliveredAt: messages.deliveredAt, + }) + .from(messages) + .where( + and( + eq(messages.conversationId, conversationId), + lt(messages.createdAt, cursorRow.createdAt) + ) + ) + .orderBy(desc(messages.createdAt)) + .limit(limit + 1); + } + } + + const rows = await query; + const hasMore = rows.length > limit; + if (hasMore) rows.pop(); + + const lastRow = rows[rows.length - 1]; + const nextCursor = hasMore && lastRow ? lastRow.id : null; + return c.json({ items: rows, nextCursor }); +}); + +// POST /api/conversations/:id/messages — Send a message +conversationsRouter.post( + "/:id/messages", + zValidator("json", sendMessageSchema), + async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); + + const conversationId = c.req.param("id"); + const { body } = c.req.valid("json"); + + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + if (!settings) return c.json({ error: "Business not found" }, 404); + + const [conv] = await db + .select({ id: conversations.id, clientId: conversations.clientId }) + .from(conversations) + .where( + and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) + ) + .limit(1); + if (!conv) return c.json({ error: "Not found" }, 404); + + const result = await sendMessage({ + businessId: settings.id, + clientId: conv.clientId, + body, + sentByStaffId: staffRow.id, + }); + + if (result.suppressed) { + return c.json({ error: "Client has opted out of SMS" }, 409); + } + + const [msg] = await db + .select({ + id: messages.id, + direction: messages.direction, + body: messages.body, + status: messages.status, + sentByStaffId: messages.sentByStaffId, + createdAt: messages.createdAt, + deliveredAt: messages.deliveredAt, + }) + .from(messages) + .where(eq(messages.id, result.messageId)) + .limit(1); + + return c.json(msg, 201); + } +); \ No newline at end of file diff --git a/packages/db/migrations/0032_staff_read_at.sql b/packages/db/migrations/0032_staff_read_at.sql new file mode 100644 index 0000000..b910ff8 --- /dev/null +++ b/packages/db/migrations/0032_staff_read_at.sql @@ -0,0 +1 @@ +ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp; \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index eef2244..95ed7f0 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -218,6 +218,20 @@ "when": 1775828067192, "tag": "0030_messaging", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1778732072097, + "tag": "0031_steady_veda", + "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1778818472097, + "tag": "0032_staff_read_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f1d74b3..c4e2f1a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -444,6 +444,7 @@ export const conversations = pgTable( status: text("status").notNull().default("active"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), + staffReadAt: timestamp("staff_read_at"), }, (t) => [ index("idx_conversations_business_id_last_message_at").on( -- 2.52.0 From a5115f5291f0e4e2b5ef70961d51c7343b85c6ff Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 07:28:31 +0000 Subject: [PATCH 08/10] fix(GRO-1208): remove unused isNull and AppEnv imports Co-Authored-By: Paperclip --- apps/api/src/__tests__/conversations.test.ts | 2 -- apps/api/src/routes/conversations.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/apps/api/src/__tests__/conversations.test.ts b/apps/api/src/__tests__/conversations.test.ts index def3528..1ee4ed7 100644 --- a/apps/api/src/__tests__/conversations.test.ts +++ b/apps/api/src/__tests__/conversations.test.ts @@ -178,8 +178,6 @@ vi.mock("../services/messaging/outbound.js", () => ({ // ─── App setup ──────────────────────────────────────────────────────────────── -import type { AppEnv } from "../middleware/rbac.js"; - const { conversationsRouter } = await import("../routes/conversations.js"); const app = new Hono(); diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts index e74b6b7..0b8eddb 100644 --- a/apps/api/src/routes/conversations.ts +++ b/apps/api/src/routes/conversations.ts @@ -12,7 +12,6 @@ import { messages, clients, businessSettings, - isNull, } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; import { sendMessage } from "../services/messaging/outbound.js"; -- 2.52.0 From 22135859c2ff9046c928ef3cac5ad776740e5dff Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 07:38:01 +0000 Subject: [PATCH 09/10] fix(GRO-1208): remove phantom 0031_steady_veda journal entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0031_steady_veda has no corresponding SQL file — caused Drizzle migration runner to exit 1 in E2E. Renumber 0032_staff_read_at to idx 31. Co-Authored-By: Paperclip --- packages/db/migrations/meta/_journal.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 95ed7f0..ff0c252 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -222,13 +222,6 @@ { "idx": 31, "version": "7", - "when": 1778732072097, - "tag": "0031_steady_veda", - "breakpoints": true - }, - { - "idx": 32, - "version": "7", "when": 1778818472097, "tag": "0032_staff_read_at", "breakpoints": true -- 2.52.0 From dce9c96442695d5861abb9b5a62f5ef8c8c72d86 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 08:29:10 +0000 Subject: [PATCH 10/10] fix(GRO-1211): skip auth middleware for /api/webhooks/* routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The telnyx webhook handler at /api/webhooks/telnyx/messaging was returning 401 for all requests including those with valid signatures. This was caused by the authMiddleware being applied to all /api/* routes via api.use("*", authMiddleware) after the webhook route was registered at the app level. authMiddleware already skips /api/auth/ paths; adding the same skip for /api/webhooks/* fixes the issue — webhook endpoints use their own signature validation and do not require Better-Auth session auth. Root cause: authMiddleware was applied to webhook routes that were registered at the app level before the api sub-app middleware, but the skip condition only covered /api/auth/, not /api/webhooks/. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/middleware/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 906f505..198f55b 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -23,7 +23,8 @@ if (process.env.AUTH_DISABLED === "true") { } export const authMiddleware: MiddlewareHandler = async (c, next) => { - if (c.req.path.startsWith("/api/auth/")) { + const path = c.req.path; + if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) { await next(); return; } -- 2.52.0