Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b646d9e5d | |||
| 1c4453ed45 | |||
| 701889c06f | |||
| c79b5220a4 | |||
| 2e24c371c3 | |||
| 5e103a378c | |||
| a7bcce8b80 | |||
| 5f1582a3b6 | |||
| c76ea93c29 | |||
| aa5686bed1 | |||
| 775e2e544b | |||
| fb9c922182 | |||
| 1cc48f0b88 | |||
| 1b8d7087c0 | |||
| d65d121a5d | |||
| b8fd7ec18f | |||
| fe2e093b92 | |||
| ad80722eee |
@@ -24,6 +24,7 @@
|
|||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
"telnyx": "^1.23.0",
|
"telnyx": "^1.23.0",
|
||||||
|
"uuid": "^11.0.5",
|
||||||
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { devRouter } from "./routes/dev.js";
|
|||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||||
|
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -69,6 +70,9 @@ app.route("/api/portal", portalRouter);
|
|||||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
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
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ invoicesRouter.get(
|
|||||||
paymentMethod: invoices.paymentMethod,
|
paymentMethod: invoices.paymentMethod,
|
||||||
paidAt: invoices.paidAt,
|
paidAt: invoices.paidAt,
|
||||||
notes: invoices.notes,
|
notes: invoices.notes,
|
||||||
|
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||||
|
stripeRefundId: invoices.stripeRefundId,
|
||||||
createdAt: invoices.createdAt,
|
createdAt: invoices.createdAt,
|
||||||
updatedAt: invoices.updatedAt,
|
updatedAt: invoices.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -128,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => {
|
|||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json({ ...invoice, lineItems, tipSplits });
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save tip splits for an invoice (replaces existing splits)
|
// Save tip splits for an invoice (replaces existing splits)
|
||||||
@@ -448,9 +460,6 @@ invoicesRouter.post(
|
|||||||
if (invoice.status !== "paid") {
|
if (invoice.status !== "paid") {
|
||||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||||
}
|
}
|
||||||
if (!invoice.stripePaymentIntentId) {
|
|
||||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
if (body.idempotencyKey) {
|
if (body.idempotencyKey) {
|
||||||
@@ -463,57 +472,75 @@ invoicesRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
let refundId: string;
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const result = await processRefund(id, body.amountCents);
|
||||||
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
|
refundId = result.refundId;
|
||||||
|
} else {
|
||||||
|
// Manual refund — no Stripe call needed
|
||||||
|
refundId = `manual_${id}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
await tx.insert(refunds).values({
|
await tx.insert(refunds).values({
|
||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
stripeRefundId: result.refundId,
|
stripeRefundId: refundId,
|
||||||
idempotencyKey: body.idempotencyKey ?? null,
|
idempotencyKey: body.idempotencyKey ?? null,
|
||||||
amountCents: body.amountCents ?? null,
|
amountCents: body.amountCents ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ refundId: result.refundId });
|
return c.json({ refundId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Payment stats for admin dashboard
|
// Payment stats for admin dashboard
|
||||||
invoicesRouter.get("/stats/summary", async (c) => {
|
invoicesRouter.get("/stats/summary", async (c) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const now = new Date();
|
const db = getDb();
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
const [revenueResult] = await db
|
const [revenueResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||||
|
|
||||||
const [outstandingResult] = await db
|
const [outstandingResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.status, "pending"));
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
const [refundsResult] = await db
|
const [refundsResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||||
.from(refunds)
|
.from(refunds)
|
||||||
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||||
|
|
||||||
const methodBreakdown = await db
|
const methodBreakdown = await db
|
||||||
.select({
|
.select({
|
||||||
method: invoices.paymentMethod,
|
method: invoices.paymentMethod,
|
||||||
total: sql<number>`count(*)`,
|
total: sql<number>`count(*)`,
|
||||||
})
|
})
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||||
.groupBy(invoices.paymentMethod);
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
revenueThisMonth: revenueResult?.total ?? 0,
|
revenueThisMonth: revenueResult?.total ?? 0,
|
||||||
outstanding: outstandingResult?.total ?? 0,
|
outstanding: outstandingResult?.total ?? 0,
|
||||||
refundsThisMonth: refundsResult?.total ?? 0,
|
refundsThisMonth: refundsResult?.total ?? 0,
|
||||||
methodBreakdown,
|
methodBreakdown,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("stats/summary error:", err);
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
refundsThisMonth: 0,
|
||||||
|
methodBreakdown: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,9 +112,17 @@ export function AppointmentsPage() {
|
|||||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||||
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||||
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
||||||
|
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
const weekEnd = addDays(weekStart, 6);
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadAppointments = useCallback(() => {
|
const loadAppointments = useCallback(() => {
|
||||||
const from = weekStart.toISOString();
|
const from = weekStart.toISOString();
|
||||||
const to = addDays(weekStart, 7).toISOString();
|
const to = addDays(weekStart, 7).toISOString();
|
||||||
@@ -314,6 +322,24 @@ export function AppointmentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Stats Summary */}
|
||||||
|
{paymentStats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||||
|
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── View Mode + Groomer Filters ── */}
|
{/* ── View Mode + Groomer Filters ── */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||||
|
|||||||
@@ -173,22 +173,21 @@ function InvoiceDetailModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||||
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
const [partialAmount, setPartialAmount] = useState("");
|
const [refundAmount, setRefundAmount] = useState("");
|
||||||
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
|
const [refundError, setRefundError] = useState<string | null>(null);
|
||||||
|
const [refunding, setRefunding] = useState(false);
|
||||||
|
|
||||||
// Fetch Stripe details when modal opens for paid invoices with a payment intent
|
// Fetch current staff role to determine manager access
|
||||||
|
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
|
fetch("/api/staff/me")
|
||||||
fetch(`/api/invoices/${invoice.id}/stripe-details`)
|
.then((r) => r.json())
|
||||||
.then((r) => r.ok ? r.json() : null)
|
.then((d) => setStaffMe(d))
|
||||||
.then((data) => { if (data) setStripeDetails(data); })
|
.catch(() => setStaffMe(null));
|
||||||
.catch(() => {});
|
}, []);
|
||||||
} else {
|
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
|
||||||
setStripeDetails(null);
|
|
||||||
}
|
|
||||||
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
|
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -292,35 +291,6 @@ function InvoiceDetailModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function issueRefund() {
|
|
||||||
const amountCents = refundType === "partial"
|
|
||||||
? Math.round(parseFloat(partialAmount) * 100)
|
|
||||||
: undefined;
|
|
||||||
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
|
|
||||||
setError("Enter a valid refund amount");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(amountCents ? { amountCents } : {}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = (await res.json()) as { error?: string };
|
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
setShowRefundDialog(false);
|
|
||||||
onUpdated();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to issue refund");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
||||||
|
|
||||||
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||||
@@ -380,15 +350,15 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||||
{stripeDetails && (
|
{invoice.stripePaymentIntentId && (
|
||||||
<>
|
<>
|
||||||
{stripeDetails.cardLast4 && (
|
{invoice.cardLast4 && (
|
||||||
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
|
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
|
||||||
)}
|
)}
|
||||||
{stripeDetails.paymentStatus && (
|
{invoice.paymentStatus && (
|
||||||
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
|
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
|
||||||
)}
|
)}
|
||||||
{stripeDetails.stripeRefundId && (
|
{invoice.stripeRefundId && (
|
||||||
<SummaryRow label="Refund" value="Refunded" />
|
<SummaryRow label="Refund" value="Refunded" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -510,77 +480,92 @@ function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||||
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
|
{invoice.stripeRefundId && (
|
||||||
<button
|
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
onClick={() => setShowRefundDialog(true)}
|
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
|
||||||
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
|
</div>
|
||||||
>
|
|
||||||
Refund
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
|
||||||
|
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
|
||||||
|
Refund
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Refund Dialog */}
|
|
||||||
{showRefundDialog && (
|
{showRefundDialog && (
|
||||||
<Modal onClose={() => setShowRefundDialog(false)}>
|
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
|
||||||
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
|
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
|
||||||
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
</p>
|
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
|
||||||
<div style={{ marginBottom: "0.75rem" }}>
|
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="refundType"
|
|
||||||
value="full"
|
|
||||||
checked={refundType === "full"}
|
|
||||||
onChange={() => setRefundType("full")}
|
|
||||||
/>
|
|
||||||
Full refund
|
Full refund
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
<input
|
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
|
||||||
type="radio"
|
|
||||||
name="refundType"
|
|
||||||
value="partial"
|
|
||||||
checked={refundType === "partial"}
|
|
||||||
onChange={() => setRefundType("partial")}
|
|
||||||
/>
|
|
||||||
Partial refund
|
Partial refund
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{refundType === "partial" && (
|
{refundType === "partial" && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="0.00"
|
placeholder="Amount ($)"
|
||||||
value={partialAmount}
|
value={refundAmount}
|
||||||
onChange={(e) => setPartialAmount(e.target.value)}
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
style={{ ...inputStyle, width: 120 }}
|
style={{ ...inputStyle, width: 100 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={issueRefund}
|
onClick={async () => {
|
||||||
disabled={saving}
|
setRefunding(true);
|
||||||
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
|
setRefundError(null);
|
||||||
|
try {
|
||||||
|
if (refundType === "partial") {
|
||||||
|
const parsed = parseFloat(refundAmount);
|
||||||
|
if (isNaN(parsed) || parsed <= 0) {
|
||||||
|
setRefundError("Please enter a valid amount greater than zero.");
|
||||||
|
setRefunding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setShowRefundDialog(false);
|
||||||
|
onUpdated();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setRefundError(e instanceof Error ? e.message : "Refund failed");
|
||||||
|
} finally {
|
||||||
|
setRefunding(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={refunding}
|
||||||
|
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
|
||||||
>
|
>
|
||||||
{saving ? "Processing…" : "Issue Refund"}
|
{refunding ? "Processing…" : "Process Refund"}
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 min-h-screen overflow-x-hidden">
|
<main className="flex-1 min-h-screen overflow-hidden">
|
||||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-stone-800">
|
<h1 className="text-lg font-semibold text-stone-800">
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap overflow-x-auto">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||||
|
|||||||
@@ -119,3 +119,10 @@ uri
|
|||||||
database-url
|
database-url
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Auth secret name — always use groombook-auth (sealed secret name)
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.authSecretName" -}}
|
||||||
|
{{- printf "%s" "groombook-auth" }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ spec:
|
|||||||
- name: OIDC_AUDIENCE
|
- name: OIDC_AUDIENCE
|
||||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.api.env.internalBaseUrl }}
|
||||||
|
- name: OIDC_INTERNAL_BASE
|
||||||
|
value: {{ .Values.api.env.internalBaseUrl | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: BETTER_AUTH_URL
|
||||||
|
value: {{ .Values.api.env.betterAuthUrl | quote }}
|
||||||
|
- name: OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_ID
|
||||||
|
- name: OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_SECRET
|
||||||
|
- name: BETTER_AUTH_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: BETTER_AUTH_SECRET
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ api:
|
|||||||
corsOrigin: ""
|
corsOrigin: ""
|
||||||
oidcIssuer: ""
|
oidcIssuer: ""
|
||||||
oidcAudience: groombook
|
oidcAudience: groombook
|
||||||
|
betterAuthUrl: ""
|
||||||
|
internalBaseUrl: ""
|
||||||
port: "3000"
|
port: "3000"
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Migration: 0030_messaging.sql
|
||||||
|
-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings
|
||||||
|
|
||||||
|
-- ─── Enums ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms');
|
||||||
|
CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound');
|
||||||
|
CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received');
|
||||||
|
CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help');
|
||||||
|
|
||||||
|
-- ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE "conversations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"business_id" uuid NOT NULL,
|
||||||
|
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
|
||||||
|
"channel" "messaging_channel" NOT NULL,
|
||||||
|
"external_number" text NOT NULL,
|
||||||
|
"business_number" text NOT NULL,
|
||||||
|
"last_message_at" timestamp,
|
||||||
|
"status" text NOT NULL DEFAULT 'active',
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamp NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC);
|
||||||
|
CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number");
|
||||||
|
|
||||||
|
CREATE TABLE "messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE,
|
||||||
|
"direction" "message_direction" NOT NULL,
|
||||||
|
"body" text,
|
||||||
|
"status" "message_status" NOT NULL DEFAULT 'queued',
|
||||||
|
"provider_message_id" text,
|
||||||
|
"error_code" text,
|
||||||
|
"error_message" text,
|
||||||
|
"sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL,
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT now(),
|
||||||
|
"delivered_at" timestamp,
|
||||||
|
"read_by_client_at" timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC);
|
||||||
|
CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id");
|
||||||
|
|
||||||
|
CREATE TABLE "message_attachments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||||
|
"content_type" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"size" integer NOT NULL,
|
||||||
|
"provider_media_id" text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id");
|
||||||
|
|
||||||
|
CREATE TABLE "message_consent_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
|
||||||
|
"business_id" uuid NOT NULL,
|
||||||
|
"kind" "message_consent_kind" NOT NULL,
|
||||||
|
"source" text,
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id");
|
||||||
|
|
||||||
|
-- ─── Business Settings extensions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text;
|
||||||
|
ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text;
|
||||||
@@ -204,6 +204,20 @@
|
|||||||
"when": 1775741667192,
|
"when": 1775741667192,
|
||||||
"tag": "0028_sms_reminders",
|
"tag": "0028_sms_reminders",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775784467192,
|
||||||
|
"tag": "0029_db_indexes_constraints",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775828067192,
|
||||||
|
"tag": "0030_messaging",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -406,6 +406,118 @@ export const impersonationAuditLogs = pgTable(
|
|||||||
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
|
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Messaging ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]);
|
||||||
|
|
||||||
|
export const messageDirectionEnum = pgEnum("message_direction", [
|
||||||
|
"inbound",
|
||||||
|
"outbound",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const messageStatusEnum = pgEnum("message_status", [
|
||||||
|
"queued",
|
||||||
|
"sent",
|
||||||
|
"delivered",
|
||||||
|
"failed",
|
||||||
|
"received",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const messageConsentKindEnum = pgEnum("message_consent_kind", [
|
||||||
|
"opt_in",
|
||||||
|
"opt_out",
|
||||||
|
"help",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const conversations = pgTable(
|
||||||
|
"conversations",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
businessId: uuid("business_id").notNull(),
|
||||||
|
clientId: uuid("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
channel: messagingChannelEnum("channel").notNull(),
|
||||||
|
externalNumber: text("external_number").notNull(),
|
||||||
|
businessNumber: text("business_number").notNull(),
|
||||||
|
lastMessageAt: timestamp("last_message_at"),
|
||||||
|
status: text("status").notNull().default("active"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_conversations_business_id_last_message_at").on(
|
||||||
|
t.businessId,
|
||||||
|
t.lastMessageAt.desc()
|
||||||
|
),
|
||||||
|
unique("uq_conversations_business_client_number").on(
|
||||||
|
t.businessId,
|
||||||
|
t.clientId,
|
||||||
|
t.businessNumber
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messages = pgTable(
|
||||||
|
"messages",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
conversationId: uuid("conversation_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||||
|
direction: messageDirectionEnum("direction").notNull(),
|
||||||
|
body: text("body"),
|
||||||
|
status: messageStatusEnum("status").notNull().default("queued"),
|
||||||
|
providerMessageId: text("provider_message_id"),
|
||||||
|
errorCode: text("error_code"),
|
||||||
|
errorMessage: text("error_message"),
|
||||||
|
sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
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(
|
||||||
|
t.conversationId,
|
||||||
|
t.createdAt.desc()
|
||||||
|
),
|
||||||
|
unique("uq_messages_provider_message_id").on(t.providerMessageId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messageAttachments = pgTable(
|
||||||
|
"message_attachments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
messageId: uuid("message_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => messages.id, { onDelete: "cascade" }),
|
||||||
|
contentType: text("content_type").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
size: integer("size").notNull(),
|
||||||
|
providerMediaId: text("provider_media_id"),
|
||||||
|
},
|
||||||
|
(t) => [index("idx_message_attachments_message_id").on(t.messageId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messageConsentEvents = pgTable(
|
||||||
|
"message_consent_events",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
clientId: uuid("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
businessId: uuid("business_id").notNull(),
|
||||||
|
kind: messageConsentKindEnum("kind").notNull(),
|
||||||
|
source: text("source"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index("idx_message_consent_events_client_id").on(t.clientId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const businessSettings = pgTable("business_settings", {
|
export const businessSettings = pgTable("business_settings", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
businessName: text("business_name").notNull().default("GroomBook"),
|
businessName: text("business_name").notNull().default("GroomBook"),
|
||||||
@@ -414,6 +526,8 @@ export const businessSettings = pgTable("business_settings", {
|
|||||||
logoKey: text("logo_key"),
|
logoKey: text("logo_key"),
|
||||||
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
||||||
accentColor: text("accent_color").notNull().default("#8b7355"),
|
accentColor: text("accent_color").notNull().default("#8b7355"),
|
||||||
|
messagingPhoneNumber: text("messaging_phone_number"),
|
||||||
|
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -978,6 +978,7 @@ async function seed() {
|
|||||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||||
|
|
||||||
|
const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
appointmentId: apptId,
|
appointmentId: apptId,
|
||||||
@@ -989,6 +990,7 @@ async function seed() {
|
|||||||
status: invoiceStatus,
|
status: invoiceStatus,
|
||||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
stripePaymentIntentId,
|
||||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1092,13 +1094,14 @@ async function seed() {
|
|||||||
const taxCents = Math.round(effectivePrice * 0.08);
|
const taxCents = Math.round(effectivePrice * 0.08);
|
||||||
const totalCents = effectivePrice + taxCents + tipCents;
|
const totalCents = effectivePrice + taxCents + tipCents;
|
||||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||||
|
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId, appointmentId: apptId, clientId,
|
id: invoiceId, appointmentId: apptId, clientId,
|
||||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||||
status: "paid" as const,
|
status: "paid" as const,
|
||||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||||
paidAt, notes: null,
|
paidAt, stripePaymentIntentId, notes: null,
|
||||||
});
|
});
|
||||||
lineItemBatch.push({
|
lineItemBatch.push({
|
||||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user