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 }; }