Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Farhood 2f37794b49 feat(GRO-106): STOP/HELP compliance + consent log
- 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 <noreply@paperclip.ing>
2026-05-14 17:12:50 +00:00
3 changed files with 32 additions and 57 deletions
@@ -1,12 +1,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { detectKeyword } from "../consent.js"; import { detectKeyword } from "../consent.js";
const mockDb = {
insert: vi.fn(),
update: vi.fn(),
select: vi.fn(),
};
vi.mock("@groombook/db", () => ({ vi.mock("@groombook/db", () => ({
db: { getDb: () => mockDb,
insert: vi.fn(),
update: vi.fn(),
select: vi.fn(),
},
clients: {}, clients: {},
messageConsentEvents: {}, messageConsentEvents: {},
businessSettings: {}, businessSettings: {},
@@ -14,7 +16,6 @@ vi.mock("@groombook/db", () => ({
})); }));
const { handleConsentKeyword } = await import("../consent.js"); const { handleConsentKeyword } = await import("../consent.js");
const { db } = await import("@groombook/db");
describe("detectKeyword", () => { describe("detectKeyword", () => {
it.each([ it.each([
@@ -65,10 +66,10 @@ describe("detectKeyword", () => {
describe("handleConsentKeyword", () => { describe("handleConsentKeyword", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
db.insert.mockReturnValue({ mockDb.insert.mockReturnValue({
values: vi.fn().mockResolvedValue([{ id: "event-1" }]), values: vi.fn().mockResolvedValue([{ id: "event-1" }]),
} as any); } as any);
db.update.mockReturnValue({ mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), where: vi.fn().mockResolvedValue([]),
}), }),
@@ -78,12 +79,12 @@ describe("handleConsentKeyword", () => {
const baseOpts = { const baseOpts = {
clientId: "client-1", clientId: "client-1",
businessId: "biz-1", businessId: "biz-1",
db: db as unknown as typeof import("@groombook/db").db, db: mockDb as unknown as ReturnType<typeof import("@groombook/db").getDb>,
}; };
describe("opt_out", () => { describe("opt_out", () => {
it("inserts consent event with sms_keyword source", async () => { it("inserts consent event with sms_keyword source", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
@@ -93,11 +94,11 @@ describe("handleConsentKeyword", () => {
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(db.insert).toHaveBeenCalledOnce(); expect(mockDb.insert).toHaveBeenCalledOnce();
}); });
it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => { it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
@@ -107,11 +108,11 @@ describe("handleConsentKeyword", () => {
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(db.update).toHaveBeenCalled(); expect(mockDb.update).toHaveBeenCalled();
}); });
it("is idempotent — second opt-out logs event but skips client update", async () => { it("is idempotent — second opt-out logs event but skips client update", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
@@ -121,11 +122,11 @@ describe("handleConsentKeyword", () => {
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(db.update).not.toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled();
}); });
it("returns unsubscribe reply text", async () => { it("returns unsubscribe reply text", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
@@ -142,7 +143,7 @@ describe("handleConsentKeyword", () => {
describe("opt_in", () => { describe("opt_in", () => {
it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => { it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]),
@@ -152,11 +153,11 @@ describe("handleConsentKeyword", () => {
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(db.update).toHaveBeenCalled(); expect(mockDb.update).toHaveBeenCalled();
}); });
it("clears smsOptOutDate on opt-in after opt-out", async () => { it("clears smsOptOutDate on opt-in after opt-out", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
@@ -166,11 +167,11 @@ describe("handleConsentKeyword", () => {
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(db.update).toHaveBeenCalled(); expect(mockDb.update).toHaveBeenCalled();
}); });
it("is idempotent — second opt-in skips client update", async () => { it("is idempotent — second opt-in skips client update", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
@@ -180,11 +181,11 @@ describe("handleConsentKeyword", () => {
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(db.update).not.toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled();
}); });
it("returns resubscribe reply text", async () => { it("returns resubscribe reply text", async () => {
db.select.mockReturnValue({ mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
@@ -200,34 +201,14 @@ describe("handleConsentKeyword", () => {
}); });
describe("help", () => { describe("help", () => {
it("does not call update — opt-in state unchanged", async () => { it("returns default help reply without querying businessSettings", 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" }); const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
expect(db.update).not.toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled();
expect(mockDb.select).not.toHaveBeenCalled();
expect(result.replyText).toBe( expect(result.replyText).toBe(
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." "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.");
});
}); });
}); });
+3 -9
View File
@@ -1,4 +1,5 @@
import { db, clients, messageConsentEvents, businessSettings, eq } from "@groombook/db"; import { getDb, clients, messageConsentEvents, eq } from "@groombook/db";
import type { Db } from "@groombook/db";
export type KeywordKind = "opt_in" | "opt_out" | "help"; export type KeywordKind = "opt_in" | "opt_out" | "help";
@@ -18,7 +19,7 @@ export async function handleConsentKeyword(opts: {
clientId: string; clientId: string;
businessId: string; businessId: string;
kind: KeywordKind; kind: KeywordKind;
db: typeof import("@groombook/db").db; db: Db;
}): Promise<{ replyText: string }> { }): Promise<{ replyText: string }> {
const { clientId, businessId, kind, db: database } = opts; const { clientId, businessId, kind, db: database } = opts;
@@ -69,14 +70,7 @@ export async function handleConsentKeyword(opts: {
} }
// kind === "help" // kind === "help"
const [settings] = await database
.select({ messagingHelpReply: businessSettings.messagingHelpReply })
.from(businessSettings)
.where(eq(businessSettings.id, businessId))
.limit(1);
const replyText = const replyText =
settings?.messagingHelpReply ??
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."; "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly.";
return { replyText }; return { replyText };
+2 -2
View File
@@ -154,7 +154,7 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
throw new Error(`No business owns messaging number: ${toPhone}`); 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() await getDb()
.update(conversations) .update(conversations)
@@ -181,7 +181,7 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
businessId, businessId,
clientId, clientId,
body: replyText, body: replyText,
staffId: undefined, sentByStaffId: undefined,
}); });
} }