Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f37794b49 |
@@ -1,317 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
// ─── Mock data ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const STAFF_ROW = {
|
|
||||||
id: "staff-uuid-1",
|
|
||||||
email: "groomer@groombook.com",
|
|
||||||
name: "Groomer",
|
|
||||||
role: "groomer" as const,
|
|
||||||
businessId: "business-uuid-1",
|
|
||||||
active: true,
|
|
||||||
userId: null,
|
|
||||||
oidcSub: null,
|
|
||||||
isSuperUser: false,
|
|
||||||
icalToken: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUSINESS_SETTINGS = {
|
|
||||||
id: "business-uuid-1",
|
|
||||||
businessName: "Test Salon",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONV_1 = {
|
|
||||||
id: "conv-uuid-1",
|
|
||||||
businessId: "business-uuid-1",
|
|
||||||
clientId: "client-uuid-1",
|
|
||||||
channel: "sms",
|
|
||||||
externalNumber: "+15551111111",
|
|
||||||
businessNumber: "+15552222222",
|
|
||||||
lastMessageAt: new Date("2025-01-10T10:00:00Z"),
|
|
||||||
status: "active",
|
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
|
||||||
updatedAt: new Date("2025-01-10T10:00:00Z"),
|
|
||||||
staffReadAt: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MSG_INBOUND_1 = {
|
|
||||||
id: "msg-uuid-1",
|
|
||||||
conversationId: "conv-uuid-1",
|
|
||||||
direction: "inbound",
|
|
||||||
body: "Hello",
|
|
||||||
status: "delivered",
|
|
||||||
sentByStaffId: null,
|
|
||||||
createdAt: new Date("2025-01-10T09:00:00Z"),
|
|
||||||
deliveredAt: new Date("2025-01-10T09:01:00Z"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MSG_OUTBOUND_1 = {
|
|
||||||
id: "msg-uuid-2",
|
|
||||||
conversationId: "conv-uuid-1",
|
|
||||||
direction: "outbound",
|
|
||||||
body: "Hi Alice!",
|
|
||||||
status: "delivered",
|
|
||||||
sentByStaffId: "staff-uuid-1",
|
|
||||||
createdAt: new Date("2025-01-10T10:00:00Z"),
|
|
||||||
deliveredAt: new Date("2025-01-10T10:01:00Z"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let selectRows: Record<string, unknown>[] = [];
|
|
||||||
let selectRows2: Record<string, unknown>[] = [];
|
|
||||||
let selectRows3: Record<string, unknown>[] = [];
|
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
|
||||||
let selectCallCount = 0;
|
|
||||||
|
|
||||||
function resetMock() {
|
|
||||||
selectRows = [];
|
|
||||||
selectRows2 = [];
|
|
||||||
selectRows3 = [];
|
|
||||||
updatedValues = [];
|
|
||||||
selectCallCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAll() {
|
|
||||||
resetMock();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockSendMessage = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
|
||||||
function makeChainable(data: unknown[]): unknown {
|
|
||||||
const arr = [...data];
|
|
||||||
const chain = new Proxy(arr, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "innerJoin") {
|
|
||||||
return () => chain;
|
|
||||||
}
|
|
||||||
if (prop === "from") {
|
|
||||||
return (table: unknown) => {
|
|
||||||
const tableName = (table as { _name?: string })._name;
|
|
||||||
const rows = tableName === "businessSettings" ? [BUSINESS_SETTINGS] : selectRows;
|
|
||||||
return makeChainable(rows);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// @ts-expect-error proxy
|
|
||||||
return target[prop];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return chain;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversations = new Proxy(
|
|
||||||
{ _name: "conversations" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
const messages = new Proxy(
|
|
||||||
{ _name: "messages" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
const clients = new Proxy(
|
|
||||||
{ _name: "clients" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
const businessSettings = new Proxy(
|
|
||||||
{ _name: "businessSettings" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: () => ({
|
|
||||||
from: (table: unknown) => {
|
|
||||||
const tableName = (table as { _name?: string })._name;
|
|
||||||
if (tableName === "businessSettings") return makeChainable([BUSINESS_SETTINGS]);
|
|
||||||
if (tableName === "messages") {
|
|
||||||
// Return selectRows3 if it has data (POST re-query), else cycle through selectRows/selectRows2
|
|
||||||
if (selectRows3.length > 0) {
|
|
||||||
return makeChainable(selectRows3);
|
|
||||||
}
|
|
||||||
if (selectCallCount === 0 || selectCallCount === 1) {
|
|
||||||
const rows = selectCallCount === 0 ? selectRows : selectRows2;
|
|
||||||
selectCallCount++;
|
|
||||||
return makeChainable(rows);
|
|
||||||
}
|
|
||||||
return makeChainable(selectRows);
|
|
||||||
}
|
|
||||||
return makeChainable(selectRows);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
update: () => ({
|
|
||||||
set: (vals: Record<string, unknown>) => ({
|
|
||||||
where: () => {
|
|
||||||
updatedValues.push(vals);
|
|
||||||
return { returning: () => [vals] };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: () => ({
|
|
||||||
values: (vals: Record<string, unknown>) => {
|
|
||||||
return { returning: () => [{ ...vals, id: "msg-uuid-new" }] };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
conversations,
|
|
||||||
messages,
|
|
||||||
clients,
|
|
||||||
businessSettings,
|
|
||||||
eq: vi.fn((a, b) => ({ type: "eq", a, b })),
|
|
||||||
and: vi.fn((...args) => ({ type: "and", args })),
|
|
||||||
desc: vi.fn((col) => ({ type: "desc", col })),
|
|
||||||
lt: vi.fn((a, b) => ({ type: "lt", a, b })),
|
|
||||||
sql: vi.fn(() => ({ __type: "sql" })),
|
|
||||||
isNull: vi.fn((col) => ({ type: "isNull", col })),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../services/messaging/outbound.js", () => ({
|
|
||||||
sendMessage: mockSendMessage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── App setup ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const { conversationsRouter } = await import("../routes/conversations.js");
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
app.use("*", async (c, next) => {
|
|
||||||
// @ts-expect-error — test-only context injection
|
|
||||||
c.set("staff", STAFF_ROW);
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
app.route("/conversations", conversationsRouter);
|
|
||||||
|
|
||||||
function jsonRequest(method: string, path: string, body?: unknown) {
|
|
||||||
return app.request(path, {
|
|
||||||
method,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => resetAll());
|
|
||||||
|
|
||||||
// ─── GET /conversations ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("GET /api/conversations", () => {
|
|
||||||
it("returns conversations sorted by recency with unread count", async () => {
|
|
||||||
selectRows = [
|
|
||||||
{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" },
|
|
||||||
];
|
|
||||||
selectRows2 = [{ count: "1" }];
|
|
||||||
const res = await app.request("/conversations");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.items).toHaveLength(1);
|
|
||||||
expect(body.items[0]!.id).toBe("conv-uuid-1");
|
|
||||||
expect(body.items[0]!.clientName).toBe("Alice");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports cursor-based pagination", async () => {
|
|
||||||
selectRows = [];
|
|
||||||
const res = await app.request("/conversations?cursor=conv-uuid-1&limit=1");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enforces max limit of 50", async () => {
|
|
||||||
selectRows = [];
|
|
||||||
const res = await app.request("/conversations?limit=200");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── GET /conversations/:id/messages ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("GET /api/conversations/:id/messages", () => {
|
|
||||||
it("returns paginated messages and marks conversation as read", async () => {
|
|
||||||
selectRows = [{ ...MSG_INBOUND_1 }, { ...MSG_OUTBOUND_1 }];
|
|
||||||
const res = await app.request("/conversations/conv-uuid-1/messages");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.items).toHaveLength(2);
|
|
||||||
expect(body.items[0]!.id).toBe("msg-uuid-1");
|
|
||||||
expect(updatedValues.some((u) => u.staffReadAt !== undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 404 when conversation belongs to different business", async () => {
|
|
||||||
selectRows = [];
|
|
||||||
const res = await app.request("/conversations/conv-uuid-other/messages");
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when not authenticated", async () => {
|
|
||||||
const appNoAuth = new Hono();
|
|
||||||
appNoAuth.route("/conversations", conversationsRouter);
|
|
||||||
const res = await appNoAuth.request("/conversations/conv-uuid-1/messages");
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── POST /conversations/:id/messages ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("POST /api/conversations/:id/messages", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetMock();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
selectRows = [{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }];
|
|
||||||
selectRows2 = [];
|
|
||||||
selectRows3 = [{ id: "msg-uuid-new", conversationId: "conv-uuid-1", direction: "outbound" as const, body: "Hello Alice!", status: "queued" as const, sentByStaffId: "staff-uuid-1", createdAt: new Date(), deliveredAt: null }];
|
|
||||||
updatedValues = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends via outbound service and returns 201", async () => {
|
|
||||||
mockSendMessage.mockResolvedValueOnce({
|
|
||||||
messageId: "msg-uuid-new",
|
|
||||||
providerMessageId: "provider-msg-1",
|
|
||||||
status: "queued",
|
|
||||||
suppressed: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
|
|
||||||
body: "Hello Alice!",
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.id).toBe("msg-uuid-new");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 409 when client opted out", async () => {
|
|
||||||
mockSendMessage.mockResolvedValueOnce({ suppressed: true });
|
|
||||||
|
|
||||||
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
|
|
||||||
body: "Hello",
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toMatch(/opted out/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 404 for cross-tenant conversation", async () => {
|
|
||||||
selectRows = [];
|
|
||||||
const res = await jsonRequest("POST", "/conversations/conv-uuid-other/messages", {
|
|
||||||
body: "Hello",
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty body", async () => {
|
|
||||||
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
|
|
||||||
body: "",
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects body over 1600 chars", async () => {
|
|
||||||
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
|
|
||||||
body: "a".repeat(1601),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -72,11 +72,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const impersonationAuditLogs = new Proxy(
|
|
||||||
{ _name: "impersonationAuditLogs" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@@ -104,15 +99,9 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
insert: () => ({
|
|
||||||
values: () => ({
|
|
||||||
returning: () => [],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
impersonationSessions,
|
impersonationSessions,
|
||||||
appointments,
|
appointments,
|
||||||
impersonationAuditLogs,
|
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,17 +40,13 @@ const EXPIRED_SESSION = {
|
|||||||
let selectRows: Record<string, unknown>[] = [];
|
let selectRows: Record<string, unknown>[] = [];
|
||||||
let selectSessionRow: Record<string, unknown> | null = null;
|
let selectSessionRow: Record<string, unknown> | null = null;
|
||||||
let insertedValues: Record<string, unknown>[] = [];
|
let insertedValues: Record<string, unknown>[] = [];
|
||||||
let insertedAuditValues: Record<string, unknown>[] = [];
|
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
let lastInsertTable: string | null = null;
|
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
selectRows = [];
|
selectRows = [];
|
||||||
selectSessionRow = null;
|
selectSessionRow = null;
|
||||||
insertedValues = [];
|
insertedValues = [];
|
||||||
insertedAuditValues = [];
|
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
lastInsertTable = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
vi.mock("@groombook/db", () => {
|
||||||
@@ -93,11 +89,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const impersonationAuditLogs = new Proxy(
|
|
||||||
{ _name: "impersonationAuditLogs" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
const appointments = new Proxy(
|
const appointments = new Proxy(
|
||||||
{ _name: "appointments" },
|
{ _name: "appointments" },
|
||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||||
@@ -116,13 +107,9 @@ vi.mock("@groombook/db", () => {
|
|||||||
return makeChainable([]);
|
return makeChainable([]);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
insert: (table: { _name: string }) => ({
|
insert: () => ({
|
||||||
values: (vals: Record<string, unknown>) => {
|
values: (vals: Record<string, unknown>) => {
|
||||||
if (table._name === "impersonationAuditLogs") {
|
insertedValues.push(vals);
|
||||||
insertedAuditValues.push(vals);
|
|
||||||
} else {
|
|
||||||
insertedValues.push(vals);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
|
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
|
||||||
};
|
};
|
||||||
@@ -156,7 +143,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
pets,
|
pets,
|
||||||
services,
|
services,
|
||||||
appointments,
|
appointments,
|
||||||
impersonationAuditLogs,
|
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
lt: vi.fn(),
|
lt: vi.fn(),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
|||||||
import { impersonationRouter } from "./routes/impersonation.js";
|
import { impersonationRouter } from "./routes/impersonation.js";
|
||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { authProviderRouter } from "./routes/authProvider.js";
|
import { authProviderRouter } from "./routes/authProvider.js";
|
||||||
import { conversationsRouter } from "./routes/conversations.js";
|
|
||||||
import { searchRouter } from "./routes/search.js";
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { getObject } from "./lib/s3.js";
|
import { getObject } from "./lib/s3.js";
|
||||||
import { calendarRouter } from "./routes/calendar.js";
|
import { calendarRouter } from "./routes/calendar.js";
|
||||||
@@ -274,7 +273,6 @@ api.route("/admin/settings", settingsRouter);
|
|||||||
api.route("/admin/auth-provider", authProviderRouter);
|
api.route("/admin/auth-provider", authProviderRouter);
|
||||||
api.route("/admin/seed", adminSeedRouter);
|
api.route("/admin/seed", adminSeedRouter);
|
||||||
api.route("/search", searchRouter);
|
api.route("/search", searchRouter);
|
||||||
api.route("/conversations", conversationsRouter);
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
await initAuth();
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
const path = c.req.path;
|
if (c.req.path.startsWith("/api/auth/")) {
|
||||||
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
|
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
|
||||||
import { z } from "zod/v3";
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
eq,
|
|
||||||
desc,
|
|
||||||
lt,
|
|
||||||
sql,
|
|
||||||
getDb,
|
|
||||||
conversations,
|
|
||||||
messages,
|
|
||||||
clients,
|
|
||||||
businessSettings,
|
|
||||||
} from "@groombook/db";
|
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
import { sendMessage } from "../services/messaging/outbound.js";
|
|
||||||
|
|
||||||
export const conversationsRouter = new Hono<AppEnv>();
|
|
||||||
|
|
||||||
const sendMessageSchema = z.object({
|
|
||||||
body: z.string().min(1).max(1600),
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/conversations — List conversations
|
|
||||||
conversationsRouter.get("/", async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const staffRow = c.get("staff");
|
|
||||||
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const [settings] = await db
|
|
||||||
.select({ id: businessSettings.id })
|
|
||||||
.from(businessSettings)
|
|
||||||
.limit(1);
|
|
||||||
if (!settings) return c.json({ error: "Business not found" }, 404);
|
|
||||||
|
|
||||||
const cursor = c.req.query("cursor") || undefined;
|
|
||||||
const limit = Math.min(Number(c.req.query("limit") || "20"), 50);
|
|
||||||
|
|
||||||
let baseQuery = db
|
|
||||||
.select({
|
|
||||||
id: conversations.id,
|
|
||||||
clientId: conversations.clientId,
|
|
||||||
lastMessageAt: conversations.lastMessageAt,
|
|
||||||
status: conversations.status,
|
|
||||||
staffReadAt: conversations.staffReadAt,
|
|
||||||
clientName: clients.name,
|
|
||||||
clientPhone: clients.phone,
|
|
||||||
channel: conversations.channel,
|
|
||||||
})
|
|
||||||
.from(conversations)
|
|
||||||
.innerJoin(clients, eq(conversations.clientId, clients.id))
|
|
||||||
.where(eq(conversations.businessId, settings.id))
|
|
||||||
.orderBy(desc(conversations.lastMessageAt))
|
|
||||||
.limit(limit + 1);
|
|
||||||
|
|
||||||
if (cursor) {
|
|
||||||
const [cursorRow] = await db
|
|
||||||
.select({ lastMessageAt: conversations.lastMessageAt })
|
|
||||||
.from(conversations)
|
|
||||||
.where(eq(conversations.id, cursor))
|
|
||||||
.limit(1);
|
|
||||||
if (cursorRow?.lastMessageAt) {
|
|
||||||
baseQuery = db
|
|
||||||
.select({
|
|
||||||
id: conversations.id,
|
|
||||||
clientId: conversations.clientId,
|
|
||||||
lastMessageAt: conversations.lastMessageAt,
|
|
||||||
status: conversations.status,
|
|
||||||
staffReadAt: conversations.staffReadAt,
|
|
||||||
clientName: clients.name,
|
|
||||||
clientPhone: clients.phone,
|
|
||||||
channel: conversations.channel,
|
|
||||||
})
|
|
||||||
.from(conversations)
|
|
||||||
.innerJoin(clients, eq(conversations.clientId, clients.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversations.businessId, settings.id),
|
|
||||||
lt(conversations.lastMessageAt, cursorRow.lastMessageAt)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(conversations.lastMessageAt))
|
|
||||||
.limit(limit + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await baseQuery;
|
|
||||||
|
|
||||||
const hasMore = rows.length > limit;
|
|
||||||
if (hasMore) rows.pop();
|
|
||||||
|
|
||||||
const items = await Promise.all(
|
|
||||||
rows.map(async (row) => {
|
|
||||||
const [unreadRow] = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(messages)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(messages.conversationId, row.id),
|
|
||||||
eq(messages.direction, "inbound"),
|
|
||||||
sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [lastMsg] = await db
|
|
||||||
.select({
|
|
||||||
body: messages.body,
|
|
||||||
direction: messages.direction,
|
|
||||||
createdAt: messages.createdAt,
|
|
||||||
})
|
|
||||||
.from(messages)
|
|
||||||
.where(eq(messages.conversationId, row.id))
|
|
||||||
.orderBy(desc(messages.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
clientId: row.clientId,
|
|
||||||
clientName: row.clientName,
|
|
||||||
clientPhone: row.clientPhone,
|
|
||||||
channel: row.channel,
|
|
||||||
lastMessageAt: row.lastMessageAt,
|
|
||||||
status: row.status,
|
|
||||||
unreadCount: Number(unreadRow?.count ?? 0),
|
|
||||||
lastMessage: lastMsg ?? null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastRow = rows[rows.length - 1];
|
|
||||||
const nextCursor = hasMore && lastRow ? lastRow.id : null;
|
|
||||||
return c.json({ items, nextCursor });
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/conversations/:id/messages — List messages for a conversation
|
|
||||||
conversationsRouter.get("/:id/messages", async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const staffRow = c.get("staff");
|
|
||||||
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const conversationId = c.req.param("id");
|
|
||||||
const cursor = c.req.query("cursor") || undefined;
|
|
||||||
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
|
|
||||||
|
|
||||||
const [settings] = await db
|
|
||||||
.select({ id: businessSettings.id })
|
|
||||||
.from(businessSettings)
|
|
||||||
.limit(1);
|
|
||||||
if (!settings) return c.json({ error: "Business not found" }, 404);
|
|
||||||
|
|
||||||
const [conv] = await db
|
|
||||||
.select({ id: conversations.id })
|
|
||||||
.from(conversations)
|
|
||||||
.where(
|
|
||||||
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!conv) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(conversations)
|
|
||||||
.set({ staffReadAt: new Date() })
|
|
||||||
.where(eq(conversations.id, conversationId));
|
|
||||||
|
|
||||||
let query = db
|
|
||||||
.select({
|
|
||||||
id: messages.id,
|
|
||||||
direction: messages.direction,
|
|
||||||
body: messages.body,
|
|
||||||
status: messages.status,
|
|
||||||
sentByStaffId: messages.sentByStaffId,
|
|
||||||
createdAt: messages.createdAt,
|
|
||||||
deliveredAt: messages.deliveredAt,
|
|
||||||
})
|
|
||||||
.from(messages)
|
|
||||||
.where(eq(messages.conversationId, conversationId))
|
|
||||||
.orderBy(desc(messages.createdAt))
|
|
||||||
.limit(limit + 1);
|
|
||||||
|
|
||||||
if (cursor) {
|
|
||||||
const [cursorRow] = await db
|
|
||||||
.select({ createdAt: messages.createdAt })
|
|
||||||
.from(messages)
|
|
||||||
.where(eq(messages.id, cursor))
|
|
||||||
.limit(1);
|
|
||||||
if (cursorRow?.createdAt) {
|
|
||||||
query = db
|
|
||||||
.select({
|
|
||||||
id: messages.id,
|
|
||||||
direction: messages.direction,
|
|
||||||
body: messages.body,
|
|
||||||
status: messages.status,
|
|
||||||
sentByStaffId: messages.sentByStaffId,
|
|
||||||
createdAt: messages.createdAt,
|
|
||||||
deliveredAt: messages.deliveredAt,
|
|
||||||
})
|
|
||||||
.from(messages)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(messages.conversationId, conversationId),
|
|
||||||
lt(messages.createdAt, cursorRow.createdAt)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(messages.createdAt))
|
|
||||||
.limit(limit + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await query;
|
|
||||||
const hasMore = rows.length > limit;
|
|
||||||
if (hasMore) rows.pop();
|
|
||||||
|
|
||||||
const lastRow = rows[rows.length - 1];
|
|
||||||
const nextCursor = hasMore && lastRow ? lastRow.id : null;
|
|
||||||
return c.json({ items: rows, nextCursor });
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/conversations/:id/messages — Send a message
|
|
||||||
conversationsRouter.post(
|
|
||||||
"/:id/messages",
|
|
||||||
zValidator("json", sendMessageSchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const staffRow = c.get("staff");
|
|
||||||
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const conversationId = c.req.param("id");
|
|
||||||
const { body } = c.req.valid("json");
|
|
||||||
|
|
||||||
const [settings] = await db
|
|
||||||
.select({ id: businessSettings.id })
|
|
||||||
.from(businessSettings)
|
|
||||||
.limit(1);
|
|
||||||
if (!settings) return c.json({ error: "Business not found" }, 404);
|
|
||||||
|
|
||||||
const [conv] = await db
|
|
||||||
.select({ id: conversations.id, clientId: conversations.clientId })
|
|
||||||
.from(conversations)
|
|
||||||
.where(
|
|
||||||
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!conv) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
const result = await sendMessage({
|
|
||||||
businessId: settings.id,
|
|
||||||
clientId: conv.clientId,
|
|
||||||
body,
|
|
||||||
sentByStaffId: staffRow.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.suppressed) {
|
|
||||||
return c.json({ error: "Client has opted out of SMS" }, 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [msg] = await db
|
|
||||||
.select({
|
|
||||||
id: messages.id,
|
|
||||||
direction: messages.direction,
|
|
||||||
body: messages.body,
|
|
||||||
status: messages.status,
|
|
||||||
sentByStaffId: messages.sentByStaffId,
|
|
||||||
createdAt: messages.createdAt,
|
|
||||||
deliveredAt: messages.deliveredAt,
|
|
||||||
})
|
|
||||||
.from(messages)
|
|
||||||
.where(eq(messages.id, result.messageId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return c.json(msg, 201);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -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<typeof import("@groombook/db").getDb>,
|
||||||
|
};
|
||||||
|
|
||||||
|
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."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
|
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { detectKeyword, handleConsentKeyword } from "./consent.js";
|
||||||
|
import { sendMessage } from "./outbound.js";
|
||||||
|
|
||||||
export interface TelnyxMessageReceivedPayload {
|
export interface TelnyxMessageReceivedPayload {
|
||||||
data: {
|
data: {
|
||||||
@@ -152,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)
|
||||||
@@ -167,6 +169,22 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
|
|||||||
"received"
|
"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 };
|
return { conversationId, messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
|
|
||||||
@@ -218,13 +218,6 @@
|
|||||||
"when": 1775828067192,
|
"when": 1775828067192,
|
||||||
"tag": "0030_messaging",
|
"tag": "0030_messaging",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 31,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1778818472097,
|
|
||||||
"tag": "0032_staff_read_at",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -444,7 +444,6 @@ export const conversations = pgTable(
|
|||||||
status: text("status").notNull().default("active"),
|
status: text("status").notNull().default("active"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
staffReadAt: timestamp("staff_read_at"),
|
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index("idx_conversations_business_id_last_message_at").on(
|
index("idx_conversations_business_id_last_message_at").on(
|
||||||
|
|||||||
Reference in New Issue
Block a user