Compare commits

...

6 Commits

Author SHA1 Message Date
Chris Farhood 0334539d02 fix(GRO-1213): add missing impersonationAuditLogs mock in waitlist.test.ts
portalAudit middleware calls db.insert(impersonationAuditLogs) on every
portal request. The waitlist test mock was missing this export, causing
insertedValues to be double-counted (once for waitlist entry, once for
audit log).

Added impersonationAuditLogs proxy and separate insertedAuditValues
tracking to isolate audit log inserts from business-logic inserts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:11:28 +00:00
Chris Farhood a9b9a0a733 fix(GRO-1212): add missing impersonationAuditLogs mock in portal.test.ts
Add impersonationAuditLogs table mock and db.insert() method to the
@groombook/db mock in portal.test.ts to resolve "No 'impersonationAuditLogs'
export is defined" errors. The portalAudit middleware calls db.insert()
on every request, which was missing from the mock.

Passes all 26 portal tests.
2026-05-14 08:50:01 +00:00
Chris Farhood dce9c96442 fix(GRO-1211): skip auth middleware for /api/webhooks/* routes
The telnyx webhook handler at /api/webhooks/telnyx/messaging was
returning 401 for all requests including those with valid signatures.
This was caused by the authMiddleware being applied to all /api/*
routes via api.use("*", authMiddleware) after the webhook route was
registered at the app level.

authMiddleware already skips /api/auth/ paths; adding the same skip
for /api/webhooks/* fixes the issue — webhook endpoints use their own
signature validation and do not require Better-Auth session auth.

Root cause: authMiddleware was applied to webhook routes that were
registered at the app level before the api sub-app middleware, but
the skip condition only covered /api/auth/, not /api/webhooks/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 08:29:10 +00:00
Chris Farhood 22135859c2 fix(GRO-1208): remove phantom 0031_steady_veda journal entry
0031_steady_veda has no corresponding SQL file — caused Drizzle migration
runner to exit 1 in E2E. Renumber 0032_staff_read_at to idx 31.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:38:01 +00:00
Chris Farhood a5115f5291 fix(GRO-1208): remove unused isNull and AppEnv imports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:28:31 +00:00
Chris Farhood e64538822d feat(GRO-1208): add staff conversations API route and staffReadAt migration
- Add `staffReadAt` column to conversations table schema
- Add migration 0032_staff_read_at.sql for the new column
- Create /api/conversations router with GET / (list), GET /:id/messages (paginated), POST /:id/messages (send)
- Mark conversations as read (staffReadAt = NOW()) when staff fetches messages
- Return 409 when client has opted out of SMS
- 404 on cross-tenant access
- Add conversations.test.ts covering all 5 acceptance criteria

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:10:43 +00:00
9 changed files with 631 additions and 3 deletions
@@ -0,0 +1,317 @@
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);
});
});
+11
View File
@@ -72,6 +72,11 @@ vi.mock("@groombook/db", () => {
{ 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 {
getDb: () => ({
select: () => ({
@@ -99,9 +104,15 @@ vi.mock("@groombook/db", () => {
}),
}),
}),
insert: () => ({
values: () => ({
returning: () => [],
}),
}),
}),
impersonationSessions,
appointments,
impersonationAuditLogs,
eq: vi.fn(),
and: vi.fn(),
};
+16 -2
View File
@@ -40,13 +40,17 @@ const EXPIRED_SESSION = {
let selectRows: Record<string, unknown>[] = [];
let selectSessionRow: Record<string, unknown> | null = null;
let insertedValues: Record<string, unknown>[] = [];
let insertedAuditValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let lastInsertTable: string | null = null;
function resetMock() {
selectRows = [];
selectSessionRow = null;
insertedValues = [];
insertedAuditValues = [];
updatedValues = [];
lastInsertTable = null;
}
vi.mock("@groombook/db", () => {
@@ -89,6 +93,11 @@ vi.mock("@groombook/db", () => {
{ 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(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
@@ -107,9 +116,13 @@ vi.mock("@groombook/db", () => {
return makeChainable([]);
},
}),
insert: () => ({
insert: (table: { _name: string }) => ({
values: (vals: Record<string, unknown>) => {
insertedValues.push(vals);
if (table._name === "impersonationAuditLogs") {
insertedAuditValues.push(vals);
} else {
insertedValues.push(vals);
}
return {
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
};
@@ -143,6 +156,7 @@ vi.mock("@groombook/db", () => {
pets,
services,
appointments,
impersonationAuditLogs,
eq: vi.fn(),
and: vi.fn(),
lt: vi.fn(),
+2
View File
@@ -18,6 +18,7 @@ import { groomingLogsRouter } from "./routes/groomingLogs.js";
import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js";
import { conversationsRouter } from "./routes/conversations.js";
import { searchRouter } from "./routes/search.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
@@ -273,6 +274,7 @@ api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/conversations", conversationsRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
+2 -1
View File
@@ -23,7 +23,8 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.path.startsWith("/api/auth/")) {
const path = c.req.path;
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
await next();
return;
}
+274
View File
@@ -0,0 +1,274 @@
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 @@
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
@@ -218,6 +218,13 @@
"when": 1775828067192,
"tag": "0030_messaging",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1778818472097,
"tag": "0032_staff_read_at",
"breakpoints": true
}
]
}
+1
View File
@@ -444,6 +444,7 @@ export const conversations = pgTable(
status: text("status").notNull().default("active"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
staffReadAt: timestamp("staff_read_at"),
},
(t) => [
index("idx_conversations_business_id_last_message_at").on(