promote: dev → uat (GRO-1208 conversations API + GRO-1211 telnyx webhook fix)
promote: dev → uat (GRO-1208 conversations API + GRO-1211 telnyx webhook fix)
This commit was merged in pull request #400.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ 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";
|
||||||
@@ -273,6 +274,7 @@ 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,7 +23,8 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
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();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
"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,6 +444,7 @@ 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