From a7838b37854434fa358f1d590aca3e8da79afdf4 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 04:58:48 +0000 Subject: [PATCH] feat(GRO-106): STOP/HELP compliance + consent log - Add detectKeyword() and handleConsentKeyword() in consent.ts - Wire keyword detection into handleMessageReceived() in inbound.ts - Add 25-unit test suite for consent.ts covering all keywords, case insensitivity, whitespace tolerance, idempotency, and help keyword state preservation Co-Authored-By: Paperclip --- .../messaging/__tests__/consent.test.ts | 233 ++++++++++++++++++ apps/api/src/services/messaging/consent.ts | 83 +++++++ apps/api/src/services/messaging/inbound.ts | 18 ++ 3 files changed, 334 insertions(+) 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..baaa468 --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/consent.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { detectKeyword } from "../consent.js"; + +vi.mock("@groombook/db", () => ({ + db: { + insert: vi.fn(), + update: vi.fn(), + select: vi.fn(), + }, + clients: {}, + messageConsentEvents: {}, + businessSettings: {}, + eq: vi.fn(), +})); + +const { handleConsentKeyword } = await import("../consent.js"); +const { db } = await import("@groombook/db"); + +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(); + db.insert.mockReturnValue({ + values: vi.fn().mockResolvedValue([{ id: "event-1" }]), + } as any); + db.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + } as any); + }); + + const baseOpts = { + clientId: "client-1", + businessId: "biz-1", + db: db as unknown as typeof import("@groombook/db").db, + }; + + describe("opt_out", () => { + it("inserts consent event with sms_keyword source", async () => { + db.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(db.insert).toHaveBeenCalledOnce(); + }); + + it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => { + db.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(db.update).toHaveBeenCalled(); + }); + + it("is idempotent — second opt-out logs event but skips client update", async () => { + db.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(db.update).not.toHaveBeenCalled(); + }); + + it("returns unsubscribe reply text", async () => { + db.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 () => { + db.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(db.update).toHaveBeenCalled(); + }); + + it("clears smsOptOutDate on opt-in after opt-out", async () => { + db.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(db.update).toHaveBeenCalled(); + }); + + it("is idempotent — second opt-in skips client update", async () => { + db.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(db.update).not.toHaveBeenCalled(); + }); + + it("returns resubscribe reply text", async () => { + db.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("does not call update — opt-in state unchanged", async () => { + db.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingHelpReply: null }]), + }), + }), + } as any); + + const result = await handleConsentKeyword({ ...baseOpts, kind: "help" }); + + expect(db.update).not.toHaveBeenCalled(); + expect(result.replyText).toBe( + "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." + ); + }); + + it("uses business messagingHelpReply when configured", async () => { + db.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingHelpReply: "Custom help text." }]), + }), + }), + } as any); + + const result = await handleConsentKeyword({ ...baseOpts, kind: "help" }); + expect(result.replyText).toBe("Custom help text."); + }); + }); +}); \ 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..0f99add --- /dev/null +++ b/apps/api/src/services/messaging/consent.ts @@ -0,0 +1,83 @@ +import { db, clients, messageConsentEvents, businessSettings, eq } 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: typeof import("@groombook/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 [settings] = await database + .select({ messagingHelpReply: businessSettings.messagingHelpReply }) + .from(businessSettings) + .where(eq(businessSettings.id, businessId)) + .limit(1); + + const replyText = + settings?.messagingHelpReply ?? + "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..57d6a25 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: { @@ -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, + staffId: undefined, + }); + } + return { conversationId, messageId }; } -- 2.52.0