Compare commits

...

1 Commits

Author SHA1 Message Date
Chris Farhood a7838b3785 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 <noreply@paperclip.ing>
2026-05-14 04:58:48 +00:00
3 changed files with 334 additions and 0 deletions
@@ -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.");
});
});
});
@@ -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 };
}
@@ -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 };
}