Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40296bc859 | |||
| a8cb55e143 | |||
| ad1e0a2eb8 | |||
| 2134676f10 | |||
| dec4112ee5 |
@@ -11,6 +11,10 @@ AUTH_DISABLED=false
|
||||
OIDC_ISSUER=https://authentik.example.com
|
||||
OIDC_AUDIENCE=groombook
|
||||
|
||||
# ── Webhooks ─────────────────────────────────────────────────────────────────
|
||||
# Telnyx webhook secret for validating inbound message webhooks.
|
||||
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
|
||||
|
||||
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
||||
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
||||
# super user exists in the database. Useful in dev/test environments where the
|
||||
|
||||
@@ -24,15 +24,12 @@
|
||||
"nodemailer": "^6.9.16",
|
||||
"stripe": "^22.0.0",
|
||||
"telnyx": "^1.23.0",
|
||||
"uuid": "^11.0.5",
|
||||
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.18.0",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -29,7 +29,6 @@ import { devRouter } from "./routes/dev.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -70,9 +69,6 @@ app.route("/api/portal", portalRouter);
|
||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
||||
|
||||
// Public Telnyx messaging webhook — signature-verified, no auth required
|
||||
app.route("/api/webhooks/telnyx", telnyxWebhooksRouter);
|
||||
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
@@ -247,6 +250,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { createHmac } from "crypto";
|
||||
import {
|
||||
handleMessageReceived,
|
||||
handleMessageFinalized,
|
||||
TelnyxMessageReceivedPayload,
|
||||
} from "../../services/messaging/inbound.js";
|
||||
|
||||
export const telnyxWebhooksRouter = new Hono();
|
||||
|
||||
function validateTelnyxSignature(rawBody: string, signature: string | null): boolean {
|
||||
if (!signature) return false;
|
||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
try {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
const expected = `sha256=${hmac.update(rawBody).digest("hex")}`;
|
||||
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sigBuf.length; i++) {
|
||||
const sigByte = sigBuf[i] ?? 0;
|
||||
const expByte = expBuf[i] ?? 0;
|
||||
diff |= sigByte ^ expByte;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
telnyxWebhooksRouter.post("/messaging", async (c) => {
|
||||
const signature = c.req.header("telnyx-signature");
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await c.req.text();
|
||||
} catch {
|
||||
return c.json({ error: "Could not read body" }, 400);
|
||||
}
|
||||
|
||||
if (!validateTelnyxSignature(rawBody, signature ?? null)) {
|
||||
return c.json({ error: "Invalid signature" }, 401);
|
||||
}
|
||||
|
||||
let payload: TelnyxMessageReceivedPayload;
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as TelnyxMessageReceivedPayload;
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const eventType = payload.data?.event_type;
|
||||
if (!eventType) {
|
||||
return c.json({ error: "Missing event_type" }, 400);
|
||||
}
|
||||
|
||||
if (eventType === "message.received") {
|
||||
try {
|
||||
await handleMessageReceived(payload);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
if (msg.startsWith("No business owns")) {
|
||||
return c.json({ error: "Unknown messaging number" }, 404);
|
||||
}
|
||||
return c.json({ error: msg }, 500);
|
||||
}
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
if (eventType === "message.finalized") {
|
||||
const result = await handleMessageFinalized(payload);
|
||||
if (result) {
|
||||
return c.json({ received: true, messageId: result.messageId, status: result.newStatus });
|
||||
}
|
||||
return c.json({ received: true, messageId: null });
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
findOrCreateConversation,
|
||||
upsertMessage,
|
||||
handleMessageReceived,
|
||||
handleMessageFinalized,
|
||||
TelnyxMessageReceivedPayload,
|
||||
} from "../inbound.js";
|
||||
import * as schema from "@groombook/db";
|
||||
|
||||
vi.mock("@groombook/db", () => ({
|
||||
getDb: vi.fn(),
|
||||
conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null },
|
||||
messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null },
|
||||
businessSettings: { id: "", messagingPhoneNumber: "" },
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
sql: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockDb = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType<typeof schema.getDb>);
|
||||
|
||||
const makePayload = (
|
||||
eventType: "message.received" | "message.sent" | "message.finalized",
|
||||
messageId: string,
|
||||
fromPhone: string,
|
||||
toPhone: string,
|
||||
body = "Hello"
|
||||
): TelnyxMessageReceivedPayload => ({
|
||||
data: {
|
||||
id: "evt-1",
|
||||
event_type: eventType,
|
||||
payload: {
|
||||
message: {
|
||||
id: messageId,
|
||||
from: { phone: fromPhone, carrier: "carrier" },
|
||||
to: [{ phone: toPhone }],
|
||||
body,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("signature validation via route", () => {
|
||||
it("returns 401 when telnyx-signature header is missing", async () => {
|
||||
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
||||
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
||||
const req = new Request("http://localhost/api/webhooks/telnyx/messaging", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload,
|
||||
});
|
||||
const res = await telnyxWebhooksRouter.fetch(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 when signature does not match", async () => {
|
||||
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
|
||||
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
||||
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
||||
const req = new Request("http://localhost/api/webhooks/telnyx/messaging", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"telnyx-signature": "sha256=bad",
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
const res = await telnyxWebhooksRouter.fetch(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOrCreateConversation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDb.select.mockReset();
|
||||
mockDb.from.mockReset();
|
||||
mockDb.where.mockReset();
|
||||
mockDb.limit.mockReset();
|
||||
mockDb.insert.mockReset();
|
||||
mockDb.update.mockReset();
|
||||
mockDb.returning.mockReset();
|
||||
});
|
||||
|
||||
it("returns existing conversation when found", async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
||||
expect(result.id).toBe("conv-1");
|
||||
});
|
||||
|
||||
it("creates new conversation when none exists", async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
||||
expect(result.id).toBe("conv-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns isNew=false when message with providerMessageId already exists", async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received");
|
||||
expect(result.isNew).toBe(false);
|
||||
expect(result.id).toBe("msg-existing");
|
||||
});
|
||||
|
||||
it("inserts new message and returns isNew=true", async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued");
|
||||
expect(result.isNew).toBe(true);
|
||||
expect(result.id).toBe("msg-new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleMessageReceived", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDb.select.mockReset();
|
||||
mockDb.from.mockReset();
|
||||
mockDb.where.mockReset();
|
||||
mockDb.limit.mockReset();
|
||||
mockDb.insert.mockReset();
|
||||
mockDb.update.mockReset();
|
||||
mockDb.returning.mockReset();
|
||||
});
|
||||
|
||||
it("returns 404 when no business owns the to number", async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000");
|
||||
await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number");
|
||||
});
|
||||
|
||||
it("creates conversation and message for valid inbound", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([{ id: "biz-1" }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-1" }]),
|
||||
}),
|
||||
});
|
||||
mockDb.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
});
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
mockDb.insert.mockReturnValueOnce({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message");
|
||||
const result = await handleMessageReceived(payload);
|
||||
expect(result.messageId).toBe("msg-new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleMessageFinalized", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when message not found", async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222");
|
||||
const result = await handleMessageFinalized(payload);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("updates status to delivered for finalized inbound", async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
mockDb.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222");
|
||||
const result = await handleMessageFinalized(payload);
|
||||
expect(result?.newStatus).toBe("delivered");
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockSendSms = vi.fn();
|
||||
const mockGetDb = vi.fn();
|
||||
const mockUuidv4 = vi.fn();
|
||||
|
||||
vi.mock("../../sms.js", () => ({
|
||||
sendSms: mockSendSms,
|
||||
}));
|
||||
|
||||
vi.mock("@groombook/db", () => ({
|
||||
getDb: () => mockGetDb(),
|
||||
conversations: {},
|
||||
messages: {},
|
||||
clients: {},
|
||||
businessSettings: {},
|
||||
eq: vi.fn((a, b) => [a, b]),
|
||||
and: vi.fn((...args) => args),
|
||||
}));
|
||||
|
||||
vi.mock("uuid", () => ({
|
||||
v4: () => mockUuidv4(),
|
||||
}));
|
||||
|
||||
const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.ts");
|
||||
|
||||
describe("sendMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUuidv4.mockReturnValue("test-uuid");
|
||||
});
|
||||
|
||||
function buildSelectMock(results: unknown[]) {
|
||||
return vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(results),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
it("returns suppressed=true when client has no phone", async () => {
|
||||
mockGetDb.mockReturnValue({
|
||||
select: buildSelectMock([{ phone: null, smsOptIn: true }]),
|
||||
});
|
||||
|
||||
const result = await sendMessage({
|
||||
businessId: "biz-1",
|
||||
clientId: "client-1",
|
||||
body: "Hello",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ suppressed: true });
|
||||
expect(mockSendSms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns suppressed=true when client has opted out of SMS", async () => {
|
||||
mockGetDb.mockReturnValue({
|
||||
select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]),
|
||||
});
|
||||
|
||||
const result = await sendMessage({
|
||||
businessId: "biz-1",
|
||||
clientId: "client-1",
|
||||
body: "Hello",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ suppressed: true });
|
||||
expect(mockSendSms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => {
|
||||
mockGetDb.mockReturnValue({
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
|
||||
).rejects.toThrow(MissingTenantPhoneNumberError);
|
||||
});
|
||||
|
||||
it("persists provider message id on success", async () => {
|
||||
const messageId = "msg-1";
|
||||
const conversationId = "conv-1";
|
||||
|
||||
mockGetDb.mockReturnValue({
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: conversationId }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" });
|
||||
|
||||
const result = await sendMessage({
|
||||
businessId: "biz-1",
|
||||
clientId: "client-1",
|
||||
body: "Hello",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
messageId,
|
||||
providerMessageId: "provider-msg-1",
|
||||
status: "sent",
|
||||
suppressed: false,
|
||||
});
|
||||
expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined);
|
||||
});
|
||||
|
||||
it("persists error on Telnyx failure", async () => {
|
||||
const messageId = "msg-1";
|
||||
|
||||
mockGetDb.mockReturnValue({
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
mockSendSms.mockRejectedValue(new Error("Telnyx API error"));
|
||||
|
||||
await expect(
|
||||
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
|
||||
).rejects.toThrow("Telnyx API error");
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { getDb, conversations, messages, businessSettings, eq, and, sql } from "@groombook/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export interface TelnyxMessageReceivedPayload {
|
||||
data: {
|
||||
id: string;
|
||||
event_type: "message.received" | "message.sent" | "message.finalized";
|
||||
payload: {
|
||||
message: {
|
||||
id: string;
|
||||
from: { phone: string; carrier?: string };
|
||||
to: { phone: string }[];
|
||||
body: string;
|
||||
media?: Array<{ type: string; url: string }>;
|
||||
};
|
||||
recording?: unknown;
|
||||
leg_count?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateConversation(
|
||||
businessId: string,
|
||||
clientPhone: string,
|
||||
businessNumber: string
|
||||
): Promise<{ id: string; clientId: string }> {
|
||||
const db = getDb();
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: conversations.id, clientId: conversations.clientId })
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.businessId, businessId),
|
||||
eq(conversations.externalNumber, clientPhone),
|
||||
eq(conversations.businessNumber, businessNumber)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return { id: existing.id, clientId: existing.clientId };
|
||||
}
|
||||
|
||||
const [business] = await db
|
||||
.select({ primaryClientId: sql<string>`${businessSettings.id}` })
|
||||
.from(businessSettings)
|
||||
.where(eq(businessSettings.id, businessId))
|
||||
.limit(1);
|
||||
|
||||
const clientId = business?.primaryClientId ?? uuidv4();
|
||||
|
||||
const [created] = await db
|
||||
.insert(conversations)
|
||||
.values({
|
||||
id: uuidv4(),
|
||||
businessId,
|
||||
clientId,
|
||||
channel: "sms",
|
||||
externalNumber: clientPhone,
|
||||
businessNumber,
|
||||
lastMessageAt: new Date(),
|
||||
status: "active",
|
||||
})
|
||||
.returning({ id: conversations.id, clientId: conversations.clientId });
|
||||
|
||||
if (!created) throw new Error("Failed to create conversation");
|
||||
|
||||
return { id: created.id, clientId: created.clientId };
|
||||
}
|
||||
|
||||
export async function upsertMessage(
|
||||
providerMessageId: string,
|
||||
conversationId: string,
|
||||
direction: "inbound" | "outbound",
|
||||
body: string,
|
||||
status: "queued" | "sent" | "delivered" | "failed" | "received",
|
||||
sentByStaffId?: string
|
||||
): Promise<{ id: string; isNew: boolean }> {
|
||||
const db = getDb();
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: messages.id })
|
||||
.from(messages)
|
||||
.where(eq(messages.providerMessageId, providerMessageId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return { id: existing.id, isNew: false };
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
id: uuidv4(),
|
||||
conversationId,
|
||||
direction,
|
||||
body,
|
||||
status,
|
||||
providerMessageId,
|
||||
sentByStaffId: sentByStaffId ?? null,
|
||||
})
|
||||
.returning({ id: messages.id });
|
||||
|
||||
if (!inserted) throw new Error("Failed to insert message");
|
||||
|
||||
return { id: inserted.id, isNew: true };
|
||||
}
|
||||
|
||||
export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise<string | null> {
|
||||
const db = getDb();
|
||||
const [settings] = await db
|
||||
.select({ id: businessSettings.id })
|
||||
.from(businessSettings)
|
||||
.where(eq(businessSettings.messagingPhoneNumber, toNumber))
|
||||
.limit(1);
|
||||
return settings?.id ?? null;
|
||||
}
|
||||
|
||||
export async function handleMessageReceived(payload: TelnyxMessageReceivedPayload): Promise<{ conversationId: string; messageId: string }> {
|
||||
const { message } = payload.data.payload;
|
||||
const fromPhone = message.from.phone;
|
||||
const toPhone = message.to[0]?.phone;
|
||||
|
||||
if (!toPhone) {
|
||||
throw new Error("No recipient phone in payload");
|
||||
}
|
||||
|
||||
const businessId = await resolveBusinessIdByMessagingNumber(toPhone);
|
||||
if (!businessId) {
|
||||
throw new Error(`No business owns messaging number: ${toPhone}`);
|
||||
}
|
||||
|
||||
const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
|
||||
|
||||
await getDb()
|
||||
.update(conversations)
|
||||
.set({ lastMessageAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId));
|
||||
|
||||
const { id: messageId } = await upsertMessage(
|
||||
message.id,
|
||||
conversationId,
|
||||
"inbound",
|
||||
message.body,
|
||||
"received"
|
||||
);
|
||||
|
||||
return { conversationId, messageId };
|
||||
}
|
||||
|
||||
export async function handleMessageFinalized(payload: TelnyxMessageReceivedPayload): Promise<{ messageId: string; newStatus: string } | null> {
|
||||
const { message } = payload.data.payload;
|
||||
|
||||
if (!message.id) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [existing] = await db
|
||||
.select({ id: messages.id, status: messages.status })
|
||||
.from(messages)
|
||||
.where(eq(messages.providerMessageId, message.id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
let newStatus = existing.status;
|
||||
if (payload.data.event_type === "message.finalized") {
|
||||
const deliveryReceipt = message as { direction?: string; to?: Array<{ phone: string }> };
|
||||
if (deliveryReceipt.direction === "inbound") {
|
||||
newStatus = "delivered";
|
||||
}
|
||||
}
|
||||
|
||||
if (newStatus !== existing.status) {
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ status: newStatus, deliveredAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(messages.id, existing.id));
|
||||
}
|
||||
|
||||
return { messageId: existing.id, newStatus };
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { sendSms } from "../sms.js";
|
||||
|
||||
export interface SendMessageOptions {
|
||||
businessId: string;
|
||||
clientId: string;
|
||||
body: string;
|
||||
sentByStaffId?: string;
|
||||
mediaUrls?: string[];
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
messageId: string;
|
||||
providerMessageId: string;
|
||||
status: string;
|
||||
suppressed: false;
|
||||
}
|
||||
|
||||
export interface SendMessageSuppressed {
|
||||
suppressed: true;
|
||||
}
|
||||
|
||||
export type SendMessageResponse = SendMessageResult | SendMessageSuppressed;
|
||||
|
||||
export class MissingTenantPhoneNumberError extends Error {
|
||||
constructor() {
|
||||
super("Tenant messagingPhoneNumber is not configured");
|
||||
this.name = "MissingTenantPhoneNumberError";
|
||||
}
|
||||
}
|
||||
|
||||
async function findOrCreateConversation(
|
||||
businessId: string,
|
||||
clientId: string,
|
||||
externalNumber: string,
|
||||
businessNumber: string
|
||||
): Promise<{ id: string }> {
|
||||
const db = getDb();
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: conversations.id })
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.businessId, businessId),
|
||||
eq(conversations.externalNumber, externalNumber),
|
||||
eq(conversations.businessNumber, businessNumber)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) return { id: existing.id };
|
||||
|
||||
const [created] = await db
|
||||
.insert(conversations)
|
||||
.values({
|
||||
id: uuidv4(),
|
||||
businessId,
|
||||
clientId,
|
||||
channel: "sms",
|
||||
externalNumber,
|
||||
businessNumber,
|
||||
lastMessageAt: new Date(),
|
||||
status: "active",
|
||||
})
|
||||
.returning({ id: conversations.id });
|
||||
|
||||
if (!created) throw new Error("Failed to create conversation");
|
||||
|
||||
return { id: created.id };
|
||||
}
|
||||
|
||||
async function resolveFromNumber(businessId: string): Promise<string | null> {
|
||||
const db = getDb();
|
||||
const [settings] = await db
|
||||
.select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber })
|
||||
.from(businessSettings)
|
||||
.where(eq(businessSettings.id, businessId))
|
||||
.limit(1);
|
||||
return settings?.messagingPhoneNumber ?? null;
|
||||
}
|
||||
|
||||
export async function sendMessage(opts: SendMessageOptions): Promise<SendMessageResponse> {
|
||||
const db = getDb();
|
||||
const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts;
|
||||
|
||||
const [client] = await db
|
||||
.select({ phone: clients.phone, smsOptIn: clients.smsOptIn })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!client?.phone) {
|
||||
return { suppressed: true };
|
||||
}
|
||||
|
||||
if (!client.smsOptIn) {
|
||||
return { suppressed: true };
|
||||
}
|
||||
|
||||
const from = await resolveFromNumber(businessId);
|
||||
if (!from) throw new MissingTenantPhoneNumberError();
|
||||
|
||||
const to = client.phone;
|
||||
const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id;
|
||||
|
||||
const [queuedMessage] = await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
id: uuidv4(),
|
||||
conversationId,
|
||||
direction: "outbound",
|
||||
body,
|
||||
status: "queued",
|
||||
sentByStaffId: sentByStaffId ?? null,
|
||||
})
|
||||
.returning({ id: messages.id });
|
||||
|
||||
if (!queuedMessage) throw new Error("Failed to insert queued message");
|
||||
|
||||
try {
|
||||
const result = await sendSms(to, body, mediaUrls);
|
||||
|
||||
await db
|
||||
.update(messages)
|
||||
.set({
|
||||
status: "sent",
|
||||
providerMessageId: result.messageId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(messages.id, queuedMessage.id));
|
||||
|
||||
await db
|
||||
.update(conversations)
|
||||
.set({ lastMessageAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId));
|
||||
|
||||
return {
|
||||
messageId: queuedMessage.id,
|
||||
providerMessageId: result.messageId,
|
||||
status: result.status,
|
||||
suppressed: false,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorCode = err instanceof Error ? err.name : "UNKNOWN";
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
await db
|
||||
.update(messages)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorCode,
|
||||
errorMessage,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(messages.id, queuedMessage.id));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -139,4 +139,4 @@ export async function smsSend(
|
||||
|
||||
await provider.sendSms(to, body, mediaUrls);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,15 @@ test.describe("Portal Data Integrity", () => {
|
||||
});
|
||||
|
||||
test("billing section renders without JS errors", async ({ page }) => {
|
||||
// Mock billing endpoint
|
||||
await page.route("**/api/billing**", (route) =>
|
||||
route.fulfill({ json: { invoices: [], balanceCents: 0 } })
|
||||
// Mock portal billing endpoints
|
||||
await page.route("**/api/portal/config**", (route) =>
|
||||
route.fulfill({ json: { stripePublishableKey: "" } })
|
||||
);
|
||||
await page.route("**/api/portal/invoices**", (route) =>
|
||||
route.fulfill({ json: [] })
|
||||
);
|
||||
await page.route("**/api/portal/payment-methods**", (route) =>
|
||||
route.fulfill({ json: [] })
|
||||
);
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
@@ -477,7 +477,6 @@ export const messages = pgTable(
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
deliveredAt: timestamp("delivered_at"),
|
||||
readByClientAt: timestamp("read_by_client_at"),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_messages_conversation_id_created_at").on(
|
||||
|
||||
Generated
+2
@@ -4346,10 +4346,12 @@ packages:
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
|
||||
Reference in New Issue
Block a user