From dec4112ee51307c4e4a14f84e9d42f66e112a016 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 02:24:40 +0000 Subject: [PATCH 1/7] feat(GRO-106): messaging schema + migrations (#374) 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 Co-Authored-By: Paperclip * 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 Co-Authored-By: Paperclip --------- Co-authored-by: Chris Farhood Co-authored-by: Paperclip --- packages/db/migrations/0030_messaging.sql | 72 ++++++++++++++ packages/db/migrations/meta/_journal.json | 14 +++ packages/db/src/schema.ts | 113 ++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 packages/db/migrations/0030_messaging.sql diff --git a/packages/db/migrations/0030_messaging.sql b/packages/db/migrations/0030_messaging.sql new file mode 100644 index 0000000..c404505 --- /dev/null +++ b/packages/db/migrations/0030_messaging.sql @@ -0,0 +1,72 @@ +-- Migration: 0030_messaging.sql +-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings + +-- ─── Enums ─────────────────────────────────────────────────────────────────── + +CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms'); +CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound'); +CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received'); +CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help'); + +-- ─── Tables ─────────────────────────────────────────────────────────────────── + +CREATE TABLE "conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "business_id" uuid NOT NULL, + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + "channel" "messaging_channel" NOT NULL, + "external_number" text NOT NULL, + "business_number" text NOT NULL, + "last_message_at" timestamp, + "status" text NOT NULL DEFAULT 'active', + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now() +); + +CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC); +CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number"); + +CREATE TABLE "messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE, + "direction" "message_direction" NOT NULL, + "body" text, + "status" "message_status" NOT NULL DEFAULT 'queued', + "provider_message_id" text, + "error_code" text, + "error_message" text, + "sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + "delivered_at" timestamp, + "read_by_client_at" timestamp +); + +CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC); +CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id"); + +CREATE TABLE "message_attachments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE, + "content_type" text NOT NULL, + "url" text NOT NULL, + "size" integer NOT NULL, + "provider_media_id" text +); + +CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id"); + +CREATE TABLE "message_consent_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + "business_id" uuid NOT NULL, + "kind" "message_consent_kind" NOT NULL, + "source" text, + "created_at" timestamp NOT NULL DEFAULT now() +); + +CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id"); + +-- ─── Business Settings extensions ──────────────────────────────────────────── + +ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text; +ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 8db9b8d..eef2244 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -204,6 +204,20 @@ "when": 1775741667192, "tag": "0028_sms_reminders", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1775784467192, + "tag": "0029_db_indexes_constraints", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1775828067192, + "tag": "0030_messaging", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0a5eaef..f1d74b3 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -406,6 +406,117 @@ export const impersonationAuditLogs = pgTable( (t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)] ); +// ─── Messaging ─────────────────────────────────────────────────────────────── + +export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]); + +export const messageDirectionEnum = pgEnum("message_direction", [ + "inbound", + "outbound", +]); + +export const messageStatusEnum = pgEnum("message_status", [ + "queued", + "sent", + "delivered", + "failed", + "received", +]); + +export const messageConsentKindEnum = pgEnum("message_consent_kind", [ + "opt_in", + "opt_out", + "help", +]); + +export const conversations = pgTable( + "conversations", + { + id: uuid("id").primaryKey().defaultRandom(), + businessId: uuid("business_id").notNull(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + channel: messagingChannelEnum("channel").notNull(), + externalNumber: text("external_number").notNull(), + businessNumber: text("business_number").notNull(), + lastMessageAt: timestamp("last_message_at"), + status: text("status").notNull().default("active"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_conversations_business_id_last_message_at").on( + t.businessId, + t.lastMessageAt.desc() + ), + unique("uq_conversations_business_client_number").on( + t.businessId, + t.clientId, + t.businessNumber + ), + ] +); + +export const messages = pgTable( + "messages", + { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + direction: messageDirectionEnum("direction").notNull(), + body: text("body"), + status: messageStatusEnum("status").notNull().default("queued"), + providerMessageId: text("provider_message_id"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").notNull().defaultNow(), + deliveredAt: timestamp("delivered_at"), + readByClientAt: timestamp("read_by_client_at"), + }, + (t) => [ + index("idx_messages_conversation_id_created_at").on( + t.conversationId, + t.createdAt.desc() + ), + unique("uq_messages_provider_message_id").on(t.providerMessageId), + ] +); + +export const messageAttachments = pgTable( + "message_attachments", + { + id: uuid("id").primaryKey().defaultRandom(), + messageId: uuid("message_id") + .notNull() + .references(() => messages.id, { onDelete: "cascade" }), + contentType: text("content_type").notNull(), + url: text("url").notNull(), + size: integer("size").notNull(), + providerMediaId: text("provider_media_id"), + }, + (t) => [index("idx_message_attachments_message_id").on(t.messageId)] +); + +export const messageConsentEvents = pgTable( + "message_consent_events", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + businessId: uuid("business_id").notNull(), + kind: messageConsentKindEnum("kind").notNull(), + source: text("source"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("idx_message_consent_events_client_id").on(t.clientId)] +); + export const businessSettings = pgTable("business_settings", { id: uuid("id").primaryKey().defaultRandom(), businessName: text("business_name").notNull().default("GroomBook"), @@ -414,6 +525,8 @@ export const businessSettings = pgTable("business_settings", { logoKey: text("logo_key"), primaryColor: text("primary_color").notNull().default("#4f8a6f"), accentColor: text("accent_color").notNull().default("#8b7355"), + messagingPhoneNumber: text("messaging_phone_number"), + telnyxMessagingProfileId: text("telnyx_messaging_profile_id"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); From 2134676f109fbc72ecb1f2293d658bd414b361ba Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 15:05:39 +0000 Subject: [PATCH 2/7] fix(E2E): add missing API mocks for invoices stats and portal billing (#349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(E2E): add missing API mocks for invoices stats and portal billing navigation.spec.ts: - Add mock for /api/invoices/stats/summary returning the shape { revenueThisMonth, outstanding, refundsThisMonth, methodBreakdown } that InvoicesPage useEffect fetches on mount portal-data.spec.ts billing test: - Replace incorrect /api/billing** mock with correct portal endpoint mocks: /api/portal/config, /api/portal/invoices, /api/portal/payment-methods These are the actual endpoints BillingPayments component calls Both fixes address the E2E failures reported by Lint Roller on PR #348. Co-Authored-By: Paperclip * 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 * chore(GRO-720): harden .gitignore against agent runtime leaks - Add .gh-token, *.gh-token to block token files - Add .config/gh/ and **/.config/gh/ to block gh CLI config dirs - Add infra-repo and infra-repo/ to block infra checkouts - Add **/instructions/.gh-token to block per-agent token files - Add **/AGENT_HOME/** and $AGENT_HOME/** to block agent home dirs - Add .claude/ and .codex/ to block runtime directories Co-Authored-By: Claude Opus 4.6 * fix: allow groomer role to access invoices endpoint Co-Authored-By: Paperclip * feat(gro-609): add refund handling and payment stats to admin - Add stripePaymentIntentId to Invoice schema and types - Add POST /api/invoices/:id/refund endpoint (Stripe placeholder) - Add GET /api/invoices/stats/summary for payment analytics - Add refund button + dialog (full/partial) to InvoiceDetailModal - Add payment stats cards to Invoices page (revenue, outstanding, refunds, method breakdown) Ref: GRO-609 Co-Authored-By: Paperclip * feat(gro-609): add Stripe details to invoice modal and fix stats date filter - Add GET /api/invoices/:id/stripe-details endpoint to fetch card last4 and payment status from Stripe - Add getPaymentIntentDetails() to payment service - Fix stats summary query to filter by startOfMonth - Add cardLast4, paymentStatus, stripeRefundId transient fields to Invoice type - Display Stripe details (card last4, payment status, refund status) in modal - Add stripeRefundId and paymentFailureReason to Invoice schema (was missing in dev types) Ref: GRO-609 Co-Authored-By: Paperclip * fix(gro-609): fix two bugs found by CTO review 1. Refund stats now sum actual refund amounts from refunds table instead of incorrectly summing tip_cents from invoices table. 2. Stripe payment_intents.retrieve now expands payment_method so card.last4 is correctly available instead of null. Co-Authored-By: Paperclip * fix(GRO-816): update PetProfiles.tsx to use new appointments response shape - PetProfiles.tsx: update AppointmentsResponse interface to use flat appointments[] array instead of { upcoming, past } - PetProfiles.tsx: update petHistory filter to use appointments.appointments with date filter for past-only appointments - portal.ts: change /api/portal/appointments response to { appointments: [] } instead of { upcoming: [], past: [] } - portal.ts: change /api/portal/pets response field names to match frontend Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl, groomingNotes→notes Co-Authored-By: Paperclip * fix(GRO-816): remove unused 'now' variable from portal.ts appointments handler The PR refactored appointments response from { upcoming, past } to { appointments: [] } but the `now` variable used to compute those filters was left behind. ESLint correctly flags it as unused. Co-Authored-By: Paperclip * fix(e2e): mock /api/invoices/stats/summary to prevent useEffect crash on Invoices page The GRO-609 paymentStats useEffect fetches /api/invoices/stats/summary on every render. Without a mock, the response {} (from the generic // Appointments, clients, ... fallback) doesn't contain revenueThisMonth, causing the page to fail rendering before AdminLayout ever mounts. Other admin pages don't have this problem because they don't make unconditional side-effect fetches. E2E tests mock all /api/** calls, so the new endpoint needs its own mock. cc @cpfarhood * fix(GRO-867): proxy logo download through API server — eliminate mixed content All logo S3 interactions are now server-proxied: - GET /api/admin/settings/logo streams image bytes directly instead of returning a presigned S3 URL to the browser - Upload already went through POST /api/admin/settings/logo/upload - Frontend uses relative /api/admin/settings/logo path as img src, never a raw S3 URL - Appends cache-buster query param (?t=Date.now()) after upload so the browser fetches the fresh image instead of serving a stale cache Co-Authored-By: Paperclip * fix(GRO-867): replace transformToBuffer with async iteration over S3 stream transformToBuffer() does not exist on StreamingBlobPayloadOutputTypes in the AWS SDK v3 client. Use for-await-of over the async iterable body to collect chunks and Buffer.concat instead. Co-Authored-By: Claude Opus 4.6 * fix(GRO-867): c.body does not accept Buffer in Hono 4.x c.body() signature only accepts string | ArrayBuffer | ReadableStream | Uint8Array in Hono 4.x, not Node.js Buffer. Return a plain Response directly instead. Co-Authored-By: Claude Opus 4.6 * fix(GRO-867): remove unused getPresignedGetUrl import from settings.ts ESLint @typescript-eslint/no-unused-vars flagged the import. The logo proxy no longer uses pre-signed GET URLs. Co-Authored-By: Claude Opus 4.6 * fix(GRO-870): /api/branding returns raw S3 URL — add public logo proxy Add GET /api/branding/logo as a public endpoint that proxies logo bytes from S3, and change /api/branding to return logoUrl: "/api/branding/logo" instead of calling getPresignedGetUrl(). Eliminates mixed-content warnings when the branding context is consumed on unauthenticated pages (portal, login). Co-Authored-By: Paperclip * fix(gro-609): cherry-pick refund/stats fixes to dev (#358) * 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 * 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 --------- Co-authored-by: Test User Co-authored-by: Paperclip * fix(GRO-766): fix portal mobile overflow at 390px viewport - CustomerPortal.tsx: change main from overflow-x-hidden to overflow-hidden to properly clip child overflow in both axes - BillingPayments.tsx: add overflow-x-auto to tab button row so long button labels scroll instead of causing page-level overflow - PetProfiles.tsx: already has overflow-x-auto on tab row — no change needed Discovered in UAT by Shedward (DEF-2 and DEF-3 on GRO-754). Co-Authored-By: Paperclip * fix(GRO-876): wire up refund button in invoice detail modal Cherry-pick of 628ed34 to fix @typescript-eslint/no-unused-vars error on PR #351 Lint & Typecheck. The issueRefund function was defined but never called. This commit: - Removes the inline async onClick handler that bypassed issueRefund - Wires the Refund button to open setShowRefundDialog(true) instead - Uses issueRefund function (with refundAmount/refundError/refunding state) - Adds manager role check before showing refund button - Shows "Refunded" badge when invoice.stripeRefundId is set Co-Authored-By: Paperclip * fix(GRO-876): remove dead issueRefund function from InvoiceDetailModal The inline async onClick handler already calls the refund API directly. The separate issueRefund function was defined but never called, causing @typescript-eslint/no-unused-vars CI failure on PR #351. Co-Authored-By: Paperclip * fix(GRO-876): add partial refund validation and fix modal indentation * fix(GRO-818): refund button for all paid invoices, inline cardLast4, manual refund for non-Stripe - Backend refund endpoint: allow refunds on paid invoices without stripePaymentIntentId (manual refund path) - Backend GET /invoices/:id: inline fetch cardLast4 + paymentStatus from Stripe when stripePaymentIntentId present - Frontend: show Refund button on all paid invoices for managers (not just Stripe-backed ones) - Seed: add stripePaymentIntentId (pi_test_*) to ~20% of paid invoices for Stripe-path testing cc @cpfarhood * fix(GRO-887): wire OIDC + BETTER_AUTH env vars into API deployment (#369) Wire BETTER_AUTH_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, BETTER_AUTH_SECRET into API deployment. Add conditional OIDC_INTERNAL_BASE env var. Add new values betterAuthUrl + internalBaseUrl in values.yaml. Add authSecretName helper. Cherry-picked from e26718b (original GRO-898 fix). Co-authored-by: Paperclip Co-authored-by: Paperclip * fix(E2E): remove duplicate invoices/stats/summary block after general /api/invoices check Co-Authored-By: Paperclip * fix(GRO-980): restore 4-space indent on /api/invoices route handler --------- Co-authored-by: Test User Co-authored-by: Paperclip Co-authored-by: Flea Flicker Co-authored-by: Claude Opus 4.6 Co-authored-by: groombook-engineer[bot] <269742240+groombook-engineer[bot]@users.noreply.github.com> Co-authored-by: Paperclip Co-authored-by: Chris Farhood --- apps/e2e/tests/portal-data.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/e2e/tests/portal-data.spec.ts b/apps/e2e/tests/portal-data.spec.ts index 5e9b2ed..26ce82c 100644 --- a/apps/e2e/tests/portal-data.spec.ts +++ b/apps/e2e/tests/portal-data.spec.ts @@ -72,9 +72,15 @@ test.describe("Portal Data Integrity", () => { }); test("billing section renders without JS errors", async ({ page }) => { - // Mock billing endpoint - await page.route("**/api/billing**", (route) => - route.fulfill({ json: { invoices: [], balanceCents: 0 } }) + // Mock portal billing endpoints + await page.route("**/api/portal/config**", (route) => + route.fulfill({ json: { stripePublishableKey: "" } }) + ); + await page.route("**/api/portal/invoices**", (route) => + route.fulfill({ json: [] }) + ); + await page.route("**/api/portal/payment-methods**", (route) => + route.fulfill({ json: [] }) ); const consoleErrors: string[] = []; From 6c0cdb33feb13205d4ecb3920982420a5e5fdbbb Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Sun, 3 May 2026 17:53:12 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20portal=20mobile=20overflow=20?= =?UTF-8?q?=E2=80=94=20hide=20scrollbar=20on=20PetProfiles=20tab=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scrollbar-hide CSS utility to index.css (webkit + Firefox + IE) - Apply scrollbar-hide to PetProfiles tab overflow-x-auto row - BillingPayments.tsx already has overflow-x-auto + flex-wrap on dev; no change needed Fixes GRO-730: My Pets (+52px) and Billing (+61px) at 390px viewport Co-Authored-By: Paperclip --- apps/web/src/index.css | 16 ++++++---------- apps/web/src/portal/sections/PetProfiles.tsx | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 61c98ed..aedcf90 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -67,18 +67,14 @@ input:focus, select:focus, textarea:focus { /* ─── Scrollbar polish ─── */ ::-webkit-scrollbar { - width: 6px; + display: none; } - -::-webkit-scrollbar-track { - background: transparent; -} - ::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 3px; + display: none; } -::-webkit-scrollbar-thumb:hover { - background: #94a3b8; +/* ─── Scrollbar hide utility ─── */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; } diff --git a/apps/web/src/portal/sections/PetProfiles.tsx b/apps/web/src/portal/sections/PetProfiles.tsx index e9fb07b..185fa3e 100644 --- a/apps/web/src/portal/sections/PetProfiles.tsx +++ b/apps/web/src/portal/sections/PetProfiles.tsx @@ -182,7 +182,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) { )} {/* Tabs */} -
+
{([ { id: "info", label: "Basic Info", icon: PawPrint }, { id: "medical", label: "Medical", icon: Heart }, From 39f5c830499ff30bc3461b9960baf5d86695e9c3 Mon Sep 17 00:00:00 2001 From: Hugh Hackman Date: Sun, 3 May 2026 18:11:29 +0000 Subject: [PATCH 4/7] fix(GRO-730): restore global scrollbar polish, scope WebKit hide to .scrollbar-hide utility Co-Authored-By: Paperclip --- apps/web/src/index.css | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index aedcf90..6725147 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -67,10 +67,20 @@ input:focus, select:focus, textarea:focus { /* ─── Scrollbar polish ─── */ ::-webkit-scrollbar { - display: none; + width: 6px; } + +::-webkit-scrollbar-track { + background: transparent; +} + ::-webkit-scrollbar-thumb { - display: none; + background: #cbd5e1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; } /* ─── Scrollbar hide utility ─── */ @@ -78,3 +88,7 @@ input:focus, select:focus, textarea:focus { -ms-overflow-style: none; scrollbar-width: none; } + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} From 706c91b3ac032a16cc85e5f110bd0a30b6fff13f Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 15:58:11 +0000 Subject: [PATCH 5/7] docs(GRO-106): 10DLC pilot registration runbook (#375) * docs(GRO-106): 10DLC pilot registration runbook Co-Authored-By: Paperclip * fix(GRO-106): address QA review feedback - Change business_vertical from FINANCE_INSURANCE_BANKING to PROFESSIONAL_SERVICES - Fix broken internal issue links (GRO-106, GRO-981) to plain text - Add owner stamp alongside last-updated date - Fix phone placeholder in SQL and API example to use +1XXXXXXXXXX - Add trailing newline to both runbook files Co-Authored-By: Paperclip --------- Co-authored-by: Chris Farhood Co-authored-by: Paperclip --- docs/runbooks/10dlc-pilot-registration.md | 309 ++++++++++++++++++++++ docs/runbooks/README.md | 11 + 2 files changed, 320 insertions(+) create mode 100644 docs/runbooks/10dlc-pilot-registration.md create mode 100644 docs/runbooks/README.md diff --git a/docs/runbooks/10dlc-pilot-registration.md b/docs/runbooks/10dlc-pilot-registration.md new file mode 100644 index 0000000..d8d7681 --- /dev/null +++ b/docs/runbooks/10dlc-pilot-registration.md @@ -0,0 +1,309 @@ +# 10DLC Pilot Tenant Registration Runbook + +Authored for GRO-106 Phase 1. + +--- + +## Pre-Flight Checklist + +Before starting Telnyx registration, collect the following: + +| Item | Details | +|------|---------| +| Legal business name | Exact name on EIN / business registration | +| EIN (Employer Identification Number) | 9-digit IRS format: XX-XXXXXXX | +| Business type | Sole Proprietor / LLC / Corporation | +| Primary contact email | General contact address (postmaster@, info@, etc.) | +| Primary contact phone | Direct line for carrier verification | +| Website URL | Must be live and contain privacy policy | +| Sample message templates | See [Sample Templates](#sample-message-templates) below | +| Messaging use case | Customer Care / Account Notification | + +--- + +## Step 1 — Telnyx Account Requirements + +- Active Telnyx account with billing configured. +- Role required: **Admin** or **Super User** to register brands and campaigns. + +--- + +## Step 2 — Brand Registration + +### Via Telnyx Console + +1. Log in to [Telnyx Portal](https://portal.telnyx.com). +2. Navigate to **Messaging → A2P 10DLC → Brands**. +3. Click **Register Brand**. +4. Fill in: + - **Brand Name**: Legal business name + - **Legal Company Name**: Exact EIN name + - **Company Type**: Select from dropdown + - **EIN**: XX-XXXXXXX + - **Primary Contact**: Name, email, phone + - **Website**: Must be accessible + - **BusinessVertical**: Select appropriate vertical +5. Acknowledge the **Terms of Service**. +6. Submit. + +### Via API + +```bash +curl -X POST https://api.telnyx.com/v2/10dlc/brands \ + -H "Authorization: Bearer $TELNYX_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Your Legal Business Name", + "legal_company_name": "Your Legal Business Name", + "company_type": "llc", + "ein": "XX-XXXXXXX", + "primary_contact": { + "name": "Jane Doe", + "email": "compliance@example.com", + "phone": "+1XXXXXXXXXX" + }, + "website": "https://www.example.com", + "business_vertical": "PROFESSIONAL_SERVICES" + }' +``` + +**Response fields to record:** +- `brand_id` — required for campaign registration +- `brand_score` — affects campaign vetting speed + +### Expected Fees + +| Fee Type | Amount | +|----------|--------| +| Brand registration fee | ~$0 (no direct fee from Telnyx) | +| Campaign registration fee | ~$15–$25 per campaign (Telnyx fee, subject to change) | +| Carrier fees | Passed through from T-Mobile/AT&T/Verizon | + +### Expected Approval Window + +- **Vetting by Telnyx**: 1–3 business days after submission. +- **Carrier (T-Mobile/AT&T/Verizon) review**: 2–5 business days after Telnyx approval. +- Total end-to-end: **3–8 business days**. + +--- + +## Step 3 — Campaign Registration + +### Use Case Selection + +- **Primary**: Customer Care +- **Secondary**: Account Notification + +### Via Telnyx Console + +1. Navigate to **Messaging → A2P 10DLC → Campaigns**. +2. Click **Register Campaign**. +3. Select **Brand** (use the brand registered in Step 2). +4. Fill in: + - **Campaign Name**: e.g., `groombook-pilot-customer-care` + - **Use Case**: Customer Care / Account Notification + - **Sample Messages**: Paste exactly the templates from [Sample Templates](#sample-message-templates) below. + - **Description**: Brief description of messaging program + - **Estimated Volume**: Enter monthly estimate (e.g., 500) +5. Submit. + +### Via API + +```bash +curl -X POST https://api.telnyx.com/v2/10dlc/campaigns \ + -H "Authorization: Bearer $TELNYX_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "brand_id": "YOUR_BRAND_ID", + "name": "groombook-pilot-customer-care", + "use_case": "CUSTOMER_CARE", + "sample_messages": [ + "Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out.", + "Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP or call us at {{phone}}." + ], + "description": "Appointment reminders and account notifications for grooming clients", + "estimated_monthly_volume": 500 + }' +``` + +**Response fields to record:** +- `campaign_id` — required for messaging profile +- `status` — initially `PENDING`, transitions to `ACTIVE` after carrier approval + +### Campaign Vetting — STOP/HELP Language Requirements + +Every campaign **must** include compliant STOP/HELP messaging. The following must appear in your sample messages or be included in your terms of service: + +- **STOP**: Users can text `STOP` to opt out of all messages. +- **HELP**: Users can text `HELP` to receive contact information. + +Example STOP/HELP block: + +``` +Text STOP to opt out. Text HELP for help. Msg & data rates may apply. +``` + +--- + +## Step 4 — Messaging Profile + Phone Number Provisioning + +### Create Messaging Profile + +1. In Telnyx Portal, navigate to **Messaging → Messaging Profiles**. +2. Click **Create Messaging Profile**. +3. Name it (e.g., `groombook-pilot-prod`). +4. Copy the **Messaging Profile ID** (`messaging_profile_id`) — record this in the DB. + +### Provision a 10DLC Phone Number + +1. Navigate to **Messaging → Phone Numbers**. +2. Search for a number in your desired area code. +3. Confirm the number is 10DLC-capable. +4. Purchase the number. + +### Associate Number with Messaging Profile + +```bash +# Assign number to messaging profile +curl -X PATCH https://api.telnyx.com/v2/phone_numbers/YOUR_PHONE_NUMBER_ID \ + -H "Authorization: Bearer $TELNYX_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "messaging_profile_id": "YOUR_MESSAGING_PROFILE_ID" + }' +``` + +--- + +## Step 5 — Record in Database + +Once GRO-981 lands, record the following against the business record: + +### SQL Path (when GRO-981 is complete) + +```sql +UPDATE businesses +SET + messaging_phone_number = '+1XXXXXXXXXX', + telnyx_messaging_profile_id = 'YOUR_MESSAGING_PROFILE_ID', + telnyx_brand_id = 'YOUR_BRAND_ID', + telnyx_campaign_id = 'YOUR_CAMPAIGN_ID', + telnyx_brand_status = 'APPROVED', + telnyx_campaign_status = 'ACTIVE', + updated_at = NOW() +WHERE id = 'pilot_business_id'; +``` + +### Manual Admin Path (before GRO-981) + +Until GRO-981 is complete, use the Telnyx Portal to verify and record values manually in your internal ops sheet: + +| Field | Value | +|-------|-------| +| `messagingPhoneNumber` | +1XXXXXXXXXX | +| `telnyxMessagingProfileId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | +| `telnyxBrandId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | +| `telnyxCampaignId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | +| `brandStatus` | APPROVED / PENDING | +| `campaignStatus` | ACTIVE / PENDING | + +--- + +## Sample Message Templates + +These must match exactly what your system will send. Vetting reviewers compare templates against actual traffic. + +### Transactional Appointment Reminder + +``` +Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out. Msg & data rates may apply. +``` + +### Manual Staff Message + +``` +Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP for assistance or call us at {{phone}}. Msg & data rates may apply. +``` + +--- + +## Failure Modes + Retry Guidance + +### Vetting Rejection — Brand + +| Rejection Reason | Common Fix | +|-----------------|------------| +| Legal name mismatch with EIN | Ensure exact EIN name matches legal company name exactly | +| Website not accessible / missing privacy policy | Add privacy policy page to website before resubmitting | +| Incomplete primary contact | Provide direct phone and real email (no noreply) | +| High-risk business vertical | Contact Telnyx support for pre-screening before resubmitting | + +### Campaign Rejection + +| Rejection Reason | Common Fix | +|-----------------|------------| +| Sample messages do not match actual traffic | Update sample messages to match exactly what the system sends | +| Missing STOP/HELP language | Add compliant STOP/HELP block to sample messages | +| Volume estimate too low/high | Revise estimate to be realistic | +| Use case mismatch | Re-select use case that matches actual messaging | + +### Re-submission + +After fixing the rejection reason, re-submit via the same API endpoint. Telnyx will re-run vetting (typically 24–48 hours). + +--- + +## Cost Summary + +### Telnyx Fees (as of 2026) + +| Fee Type | Amount | Notes | +|----------|--------|-------| +| 10DLC number (monthly) | ~$1.00–$2.50/number | Varies by type and area code | +| Outbound message | $0.005–$0.015/message | Depends on destination carrier | +| Inbound message | Included | No charge for received messages | +| Campaign registration | ~$15–$25 one-time | Per campaign, subject to change | + +### Carrier Fees (T-Mobile / AT&T / Verizon) + +| Carrier | Outbound Fee | Notes | +|---------|-------------|-------| +| T-Mobile | ~$0.005–$0.01/message | Varies by message size (segment) | +| AT&T | ~$0.005–$0.015/message | Varies by message size (segment) | +| Verizon | ~$0.005–$0.01/message | Varies by message size (segment) | + +**Note**: Carrier fees are subject to change. Check [Telnyx pricing page](https://telnyx.com/pricing) and carrier fee schedules for current rates. + +### Example Monthly Cost (Pilot — 500 messages/month) + +| Line Item | Cost | +|-----------|------| +| 1x 10DLC number | ~$2.00 | +| 500 outbound messages | ~$5.00–$7.50 | +| Carrier pass-through | ~$2.50–$7.50 | +| **Estimated Monthly Total** | **~$9.50–$17.00** | + +--- + +## Rollback / De-provisioning + +If the pilot tenant must be de-provisioned: + +1. Release the phone number: Telnyx Portal → Phone Numbers → Release. +2. Archive the campaign: set status to `INACTIVE` via API or console. +3. Remove DB record: clear `messagingPhoneNumber`, `telnyxMessagingProfileId`, `telnyxCampaignId` fields in the business record. +4. Brand can remain registered (no harm) but will not be used. + +--- + +## Contacts + +| Resource | Contact | +|----------|---------| +| Telnyx Support | support@telnyx.com | +| Telnyx Dashboard | portal.telnyx.com | +| Internal Engineering | Raise issue in GRO-106 | + +--- + +_Owner: Engineering · Last updated: 2026-05-04_ diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md new file mode 100644 index 0000000..0f099d5 --- /dev/null +++ b/docs/runbooks/README.md @@ -0,0 +1,11 @@ +# GroomBook Runbooks + +Operational runbooks for GroomBook staff and operators. + +| Runbook | Description | Status | +|---------|-------------|--------| +| [10DLC Pilot Registration](./10dlc-pilot-registration.md) | Register a pilot grooming business as an A2P 10DLC brand + campaign on Telnyx | Active | + +--- + +_To add a runbook, create a markdown file in this directory and update this table._ From 305394baaf54a239e6ccf21ed6bdae55b1250695 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 16:03:55 +0000 Subject: [PATCH 6/7] BillingPayments: remove flex-wrap, add scrollbar-hide for mobile tabs Fixes GRO-730 portal mobile overflow Co-Authored-By: Paperclip --- apps/web/src/portal/sections/BillingPayments.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 89e3877..be6610c 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
)} -
+
{([ { id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard }, From 49dd698d229df7bb14a78405759c4ca9c3c2f0bf Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 17:55:47 +0000 Subject: [PATCH 7/7] feat(GRO-984): outbound SMS persistence Outbound-only re-scoped slice. CI green. Reviewed by Lint Roller and CTO. --- apps/api/package.json | 2 + .../messaging/__tests__/outbound.test.ts | 200 ++++++++++++++++++ apps/api/src/services/messaging/outbound.ts | 159 ++++++++++++++ pnpm-lock.yaml | 19 ++ 4 files changed, 380 insertions(+) create mode 100644 apps/api/src/services/messaging/__tests__/outbound.test.ts create mode 100644 apps/api/src/services/messaging/outbound.ts diff --git a/apps/api/package.json b/apps/api/package.json index e8d4488..a7c8876 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "nodemailer": "^6.9.16", "stripe": "^22.0.0", "telnyx": "^1.23.0", + "uuid": "^11.0.5", "zod": "^4.3.6" }, @@ -31,6 +32,7 @@ "@types/node": "^22.10.7", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", + "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", "tsx": "^4.19.2", diff --git a/apps/api/src/services/messaging/__tests__/outbound.test.ts b/apps/api/src/services/messaging/__tests__/outbound.test.ts new file mode 100644 index 0000000..38558c9 --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/outbound.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockSendSms = vi.fn(); +const mockGetDb = vi.fn(); +const mockUuidv4 = vi.fn(); + +vi.mock("../../sms.js", () => ({ + sendSms: mockSendSms, +})); + +vi.mock("@groombook/db", () => ({ + getDb: () => mockGetDb(), + conversations: {}, + messages: {}, + clients: {}, + businessSettings: {}, + eq: vi.fn((a, b) => [a, b]), + and: vi.fn((...args) => args), +})); + +vi.mock("uuid", () => ({ + v4: () => mockUuidv4(), +})); + +const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.js"); + +describe("sendMessage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUuidv4.mockReturnValue("test-uuid"); + }); + + function buildSelectMock(results: unknown[]) { + return vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(results), + }), + }), + }); + } + + it("returns suppressed=true when client has no phone", async () => { + mockGetDb.mockReturnValue({ + select: buildSelectMock([{ phone: null, smsOptIn: true }]), + }); + + const result = await sendMessage({ + businessId: "biz-1", + clientId: "client-1", + body: "Hello", + }); + + expect(result).toEqual({ suppressed: true }); + expect(mockSendSms).not.toHaveBeenCalled(); + }); + + it("returns suppressed=true when client has opted out of SMS", async () => { + mockGetDb.mockReturnValue({ + select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]), + }); + + const result = await sendMessage({ + businessId: "biz-1", + clientId: "client-1", + body: "Hello", + }); + + expect(result).toEqual({ suppressed: true }); + expect(mockSendSms).not.toHaveBeenCalled(); + }); + + it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => { + mockGetDb.mockReturnValue({ + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]), + }), + }), + }), + }); + + await expect( + sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" }) + ).rejects.toThrow(MissingTenantPhoneNumberError); + }); + + it("persists provider message id on success", async () => { + const messageId = "msg-1"; + const conversationId = "conv-1"; + + mockGetDb.mockReturnValue({ + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: conversationId }]), + }), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: messageId }]), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }); + + mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" }); + + const result = await sendMessage({ + businessId: "biz-1", + clientId: "client-1", + body: "Hello", + }); + + expect(result).toEqual({ + messageId, + providerMessageId: "provider-msg-1", + status: "sent", + suppressed: false, + }); + expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined); + }); + + it("persists error on Telnyx failure", async () => { + const messageId = "msg-1"; + + mockGetDb.mockReturnValue({ + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: messageId }]), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }); + + mockSendSms.mockRejectedValue(new Error("Telnyx API error")); + + await expect( + sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" }) + ).rejects.toThrow("Telnyx API error"); + }); +}); \ No newline at end of file diff --git a/apps/api/src/services/messaging/outbound.ts b/apps/api/src/services/messaging/outbound.ts new file mode 100644 index 0000000..cd56a85 --- /dev/null +++ b/apps/api/src/services/messaging/outbound.ts @@ -0,0 +1,159 @@ +import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db"; +import { v4 as uuidv4 } from "uuid"; +import { sendSms } from "../sms.js"; + +export interface SendMessageOptions { + businessId: string; + clientId: string; + body: string; + sentByStaffId?: string; + mediaUrls?: string[]; +} + +export interface SendMessageResult { + messageId: string; + providerMessageId: string; + status: string; + suppressed: false; +} + +export interface SendMessageSuppressed { + suppressed: true; +} + +export type SendMessageResponse = SendMessageResult | SendMessageSuppressed; + +export class MissingTenantPhoneNumberError extends Error { + constructor() { + super("Tenant messagingPhoneNumber is not configured"); + this.name = "MissingTenantPhoneNumberError"; + } +} + +async function findOrCreateConversation( + businessId: string, + clientId: string, + externalNumber: string, + businessNumber: string +): Promise<{ id: string }> { + const db = getDb(); + + const [existing] = await db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.businessId, businessId), + eq(conversations.externalNumber, externalNumber), + eq(conversations.businessNumber, businessNumber) + ) + ) + .limit(1); + + if (existing) return { id: existing.id }; + + const [created] = await db + .insert(conversations) + .values({ + id: uuidv4(), + businessId, + clientId, + channel: "sms", + externalNumber, + businessNumber, + lastMessageAt: new Date(), + status: "active", + }) + .returning({ id: conversations.id }); + + if (!created) throw new Error("Failed to create conversation"); + + return { id: created.id }; +} + +async function resolveFromNumber(businessId: string): Promise { + const db = getDb(); + const [settings] = await db + .select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber }) + .from(businessSettings) + .where(eq(businessSettings.id, businessId)) + .limit(1); + return settings?.messagingPhoneNumber ?? null; +} + +export async function sendMessage(opts: SendMessageOptions): Promise { + const db = getDb(); + const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts; + + const [client] = await db + .select({ phone: clients.phone, smsOptIn: clients.smsOptIn }) + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + + if (!client?.phone) { + return { suppressed: true }; + } + + if (!client.smsOptIn) { + return { suppressed: true }; + } + + const from = await resolveFromNumber(businessId); + if (!from) throw new MissingTenantPhoneNumberError(); + + const to = client.phone; + const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id; + + const [queuedMessage] = await db + .insert(messages) + .values({ + id: uuidv4(), + conversationId, + direction: "outbound", + body, + status: "queued", + sentByStaffId: sentByStaffId ?? null, + }) + .returning({ id: messages.id }); + + if (!queuedMessage) throw new Error("Failed to insert queued message"); + + try { + const result = await sendSms(to, body, mediaUrls); + + await db + .update(messages) + .set({ + status: "sent", + providerMessageId: result.messageId, + }) + .where(eq(messages.id, queuedMessage.id)); + + await db + .update(conversations) + .set({ lastMessageAt: new Date() }) + .where(eq(conversations.id, conversationId)); + + return { + messageId: queuedMessage.id, + providerMessageId: result.messageId, + status: result.status, + suppressed: false, + }; + } catch (err) { + const errorCode = err instanceof Error ? err.name : "UNKNOWN"; + const errorMessage = err instanceof Error ? err.message : String(err); + + await db + .update(messages) + .set({ + status: "failed", + errorCode, + errorMessage, + }) + .where(eq(messages.id, queuedMessage.id)); + + throw err; + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22f713a..f586e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: telnyx: specifier: ^1.23.0 version: 1.27.0 + uuid: + specifier: ^11.0.5 + version: 11.1.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -59,6 +62,9 @@ importers: '@types/nodemailer': specifier: ^6.4.17 version: 6.4.23 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) @@ -2334,6 +2340,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@typescript-eslint/eslint-plugin@8.57.1': resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4344,12 +4353,18 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true victory-vendor@37.3.6: @@ -6910,6 +6925,8 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@10.0.0': {} + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9014,6 +9031,8 @@ snapshots: dependencies: react: 19.2.4 + uuid@11.1.1: {} + uuid@8.3.2: {} uuid@9.0.1: {}