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 { 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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user