From 1fbe670751144c4e168454ae7fc36bfd161caa01 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 04:58:48 +0000 Subject: [PATCH 1/3] feat(GRO-106): STOP/HELP compliance + consent log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detectKeyword() and handleConsentKeyword() in consent.ts - Wire keyword detection into handleMessageReceived() in inbound.ts - Add 24-unit test suite for consent.ts covering all keywords, case insensitivity, whitespace tolerance, idempotency, and help keyword state preservation Fixes from QA review: - Use getDb() instead of non-existent db export; import Db type - Destructure clientId from findOrCreateConversation result - Rename staffId → sentByStaffId in sendMessage call - Remove messagingHelpReply query (column not yet in schema) Co-Authored-By: Paperclip --- .../messaging/__tests__/consent.test.ts | 214 ++++++++++++++++++ apps/api/src/services/messaging/consent.ts | 77 +++++++ apps/api/src/services/messaging/inbound.ts | 20 +- 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/services/messaging/__tests__/consent.test.ts create mode 100644 apps/api/src/services/messaging/consent.ts diff --git a/apps/api/src/services/messaging/__tests__/consent.test.ts b/apps/api/src/services/messaging/__tests__/consent.test.ts new file mode 100644 index 0000000..d810c9e --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/consent.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { detectKeyword } from "../consent.js"; + +const mockDb = { + insert: vi.fn(), + update: vi.fn(), + select: vi.fn(), +}; + +vi.mock("@groombook/db", () => ({ + getDb: () => mockDb, + clients: {}, + messageConsentEvents: {}, + businessSettings: {}, + eq: vi.fn(), +})); + +const { handleConsentKeyword } = await import("../consent.js"); + +describe("detectKeyword", () => { + it.each([ + ["STOP", "opt_out"], + ["STOPALL", "opt_out"], + ["UNSUBSCRIBE", "opt_out"], + ["CANCEL", "opt_out"], + ["END", "opt_out"], + ["QUIT", "opt_out"], + ])("opt-out keyword %s → opt_out", (keyword, expected) => { + expect(detectKeyword(keyword)).toEqual({ kind: expected }); + }); + + it.each([ + ["START", "opt_in"], + ["UNSTOP", "opt_in"], + ["YES", "opt_in"], + ["SUBSCRIBE", "opt_in"], + ])("opt-in keyword %s → opt_in", (keyword, expected) => { + expect(detectKeyword(keyword)).toEqual({ kind: expected }); + }); + + it.each([ + ["HELP", "help"], + ["INFO", "help"], + ])("help keyword %s → help", (keyword, expected) => { + expect(detectKeyword(keyword)).toEqual({ kind: expected }); + }); + + it("is case insensitive", () => { + expect(detectKeyword("stop")).toEqual({ kind: "opt_out" }); + expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" }); + expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" }); + }); + + it("trims whitespace", () => { + expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" }); + expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" }); + }); + + it("returns null for non-keyword messages", () => { + expect(detectKeyword("hello")).toBeNull(); + expect(detectKeyword("STOP IT")).toBeNull(); + expect(detectKeyword("help me")).toBeNull(); + }); +}); + +describe("handleConsentKeyword", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockResolvedValue([{ id: "event-1" }]), + } as any); + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + } as any); + }); + + const baseOpts = { + clientId: "client-1", + businessId: "biz-1", + db: mockDb as unknown as ReturnType, + }; + + describe("opt_out", () => { + it("inserts consent event with sms_keyword source", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + + expect(mockDb.insert).toHaveBeenCalledOnce(); + }); + + it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("is idempotent — second opt-out logs event but skips client update", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it("returns unsubscribe reply text", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + expect(result.replyText).toBe( + "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe." + ); + }); + }); + + describe("opt_in", () => { + it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("clears smsOptOutDate on opt-in after opt-out", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("is idempotent — second opt-in skips client update", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it("returns resubscribe reply text", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), + }), + }), + } as any); + + const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + expect(result.replyText).toBe( + "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply." + ); + }); + }); + + describe("help", () => { + it("returns default help reply without querying businessSettings", async () => { + const result = await handleConsentKeyword({ ...baseOpts, kind: "help" }); + + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockDb.select).not.toHaveBeenCalled(); + expect(result.replyText).toBe( + "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." + ); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/services/messaging/consent.ts b/apps/api/src/services/messaging/consent.ts new file mode 100644 index 0000000..0d28985 --- /dev/null +++ b/apps/api/src/services/messaging/consent.ts @@ -0,0 +1,77 @@ +import { getDb, clients, messageConsentEvents, eq } from "@groombook/db"; +import type { Db } from "@groombook/db"; + +export type KeywordKind = "opt_in" | "opt_out" | "help"; + +const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]); +const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]); +const HELP_KEYWORDS = new Set(["HELP", "INFO"]); + +export function detectKeyword(body: string): { kind: KeywordKind } | null { + const normalized = body.trim().toUpperCase(); + if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" }; + if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" }; + if (HELP_KEYWORDS.has(normalized)) return { kind: "help" }; + return null; +} + +export async function handleConsentKeyword(opts: { + clientId: string; + businessId: string; + kind: KeywordKind; + db: Db; +}): Promise<{ replyText: string }> { + const { clientId, businessId, kind, db: database } = opts; + + await database.insert(messageConsentEvents).values({ + clientId, + businessId, + kind, + source: "sms_keyword", + }); + + if (kind === "opt_out") { + const [existing] = await database + .select({ smsOptIn: clients.smsOptIn }) + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + + if (existing?.smsOptIn !== false) { + await database + .update(clients) + .set({ smsOptIn: false, smsOptOutDate: new Date() }) + .where(eq(clients.id, clientId)); + } + + return { + replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.", + }; + } + + if (kind === "opt_in") { + const [existing] = await database + .select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate }) + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + + if (existing?.smsOptIn !== true) { + await database + .update(clients) + .set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null }) + .where(eq(clients.id, clientId)); + } + + return { + replyText: + "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.", + }; + } + + // kind === "help" + const replyText = + "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."; + + return { replyText }; +} \ No newline at end of file diff --git a/apps/api/src/services/messaging/inbound.ts b/apps/api/src/services/messaging/inbound.ts index de9d0e4..4b41576 100644 --- a/apps/api/src/services/messaging/inbound.ts +++ b/apps/api/src/services/messaging/inbound.ts @@ -1,5 +1,7 @@ import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db"; import { v4 as uuidv4 } from "uuid"; +import { detectKeyword, handleConsentKeyword } from "./consent.js"; +import { sendMessage } from "./outbound.js"; export interface TelnyxMessageReceivedPayload { data: { @@ -152,7 +154,7 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa throw new Error(`No business owns messaging number: ${toPhone}`); } - const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone); + const { id: conversationId, clientId } = await findOrCreateConversation(businessId, fromPhone, toPhone); await getDb() .update(conversations) @@ -167,6 +169,22 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa "received" ); + const keyword = detectKeyword(message.body ?? ""); + if (keyword) { + const { replyText } = await handleConsentKeyword({ + clientId, + businessId, + kind: keyword.kind, + db: getDb(), + }); + await sendMessage({ + businessId, + clientId, + body: replyText, + sentByStaffId: undefined, + }); + } + return { conversationId, messageId }; } From 7d3adeae98896cafcffa07715a996e26f35ecd57 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 15:07:20 +0000 Subject: [PATCH 2/3] fix(GRO-1368): remove unused getDb import from consent.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDb was imported but never used — db is passed as a parameter to handleConsentKeyword. This was the primary TypeScript/lint error flagged by QA. Co-Authored-By: Paperclip --- apps/api/src/services/messaging/consent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/services/messaging/consent.ts b/apps/api/src/services/messaging/consent.ts index 0d28985..408486d 100644 --- a/apps/api/src/services/messaging/consent.ts +++ b/apps/api/src/services/messaging/consent.ts @@ -1,4 +1,4 @@ -import { getDb, clients, messageConsentEvents, eq } from "@groombook/db"; +import { clients, messageConsentEvents, eq } from "@groombook/db"; import type { Db } from "@groombook/db"; export type KeywordKind = "opt_in" | "opt_out" | "help"; From de3877b28d07cbed298a42342f00e05b03f62a07 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 19:20:07 +0000 Subject: [PATCH 3/3] docs(app): add UAT_PLAYBOOK.md section 4.20 for STOP/HELP consent handler Adds 12 test cases covering: - STOP/START/HELP flows and their auto-reply verification - Alias keywords (STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT / UNSTOP, YES, SUBSCRIBE, INFO) - Idempotency for double STOP and double START - Case-insensitivity and whitespace trimming - Non-keyword message rejection - Consent event audit log verification Refs: GRO-1205, GRO-1469, PR #426 Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 3fdc957..f4d1cd8 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -235,6 +235,23 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR | TC-APP-4.20.5 | Unread indicator | 1. Client sends a new message | Thread marked unread until staff views it | | TC-APP-4.20.6 | Cross-tenant isolation | 1. Staff from Business A attempts to read Business B conversations | 403 or empty response returned | + +### 4.21 SMS Consent (STOP/HELP Keyword Handler) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-APP-4.21.1 | STOP → unsubscribe + auto-reply | 1. Send `STOP` (case-insensitive, with whitespace) from a subscribed client's phone number | Client is opted out (`smsOptIn=false`, `smsOptOutDate` set), event is logged, user receives auto-reply: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe." | +| TC-APP-4.21.2 | START → resubscribe + auto-reply | 1. Send `START` (case-insensitive) from an opted-out client's phone number | Client is opted back in (`smsOptIn=true`, `smsConsentDate` updated, `smsOptOutDate` cleared), event is logged, user receives auto-reply: "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply." | +| TC-APP-4.21.3 | HELP → no opt-in change + default reply | 1. Send `HELP` (case-insensitive) from any client's phone number | No change to opt-in state, no database update, event is logged, user receives auto-reply: "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." | +| TC-APP-4.21.4 | STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT → opt-out | 1. Send each alias from a subscribed client's phone | Same behaviour as STOP: opt-out applied, correct reply sent | +| TC-APP-4.21.5 | UNSTOP / YES / SUBSCRIBE → opt-in | 1. Send each alias from an opted-out client's phone | Same behaviour as START: opt-in applied, correct reply sent | +| TC-APP-4.21.6 | INFO → help reply | 1. Send `INFO` from any client's phone | Same behaviour as HELP: no state change, help reply returned | +| TC-APP-4.21.7 | Double STOP (idempotency) | 1. Send `STOP` from an already-opted-out client | Event is logged, no update call made, idempotent — no duplicate update | +| TC-APP-4.21.8 | Double START (idempotency) | 1. Send `START` from an already-subscribed client | Event is logged, no update call made, idempotent — no duplicate update | +| TC-APP-4.21.9 | Case insensitivity | 1. Send `stop`, `Stop`, `sToP`, ` stop ` from subscribed client | All variants are detected and handled as opt-out | +| TC-APP-4.21.10 | Whitespace trimming | 1. Send ` START ` or `\tSTOP\n` | Keywords are trimmed before matching | +| TC-APP-4.21.11 | Non-keyword messages ignored | 1. Send `STOP IT`, `help me`, `hello` | Returns null from `detectKeyword`, no consent event inserted, no reply sent | +| TC-APP-4.21.12 | Consent event audit log | 1. After any keyword, query `messageConsentEvents` table | Record exists with correct `clientId`, `businessId`, `kind`, and `source: "sms_keyword"` | ## 5. Pass/Fail Criteria **Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented.