Compare commits

..

3 Commits

Author SHA1 Message Date
Test User a9be160c1b fix(GRO-682): pre-populate corepack cache at build time
corepack prepare now runs during Docker build (both builder and runner
stages) so the cache directory is populated before readOnlyRootFilesystem
is enforced at runtime. Previously the mkdir existed without populating
the cache, causing ENOENT errors in migrate/seed jobs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 00:35:38 +00:00
Test User dcb929be5b fix(GRO-765): remove dead upcoming/past filter code in portal appointments
The now/upcoming/past variables were unused after the response shape
change to { appointments: appts }. QA flagged them as dead code.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-17 17:22:41 +00:00
Test User 0ace23de53 fix(GRO-765): include service name in portal appointments response
- Added service JOIN to /api/portal/appointments to include name, duration,
  and price fields in the service sub-object
- Changed API response shape from { upcoming, past } to { appointments: [] }
  to match client expectations and simplify data flow
- Updated Appointments, ReportCards, and Dashboard components to transform
  the new response format (startTime → date + time) and extract serviceName,
  petName, and groomerName from nested sub-objects

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-17 12:50:07 +00:00
50 changed files with 381 additions and 3162 deletions
-4
View File
@@ -11,10 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook OIDC_AUDIENCE=groombook
# ── Webhooks ─────────────────────────────────────────────────────────────────
# Telnyx webhook secret for validating inbound message webhooks.
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
# ── Setup Wizard ───────────────────────────────────────────────────────────── # ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a # 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 # super user exists in the database. Useful in dev/test environments where the
-13
View File
@@ -8,16 +8,3 @@ dist/
.turbo/ .turbo/
coverage/ coverage/
minimax-output/ minimax-output/
# Agent runtime artifacts — never commit
.gh-token
*.gh-token
.config/gh/
**/.config/gh/
infra-repo
infra-repo/
**/instructions/.gh-token
**/AGENT_HOME/**
$AGENT_HOME/**
.claude/
.codex/
+5 -2
View File
@@ -12,7 +12,8 @@ RUN pnpm install --frozen-lockfile
# Build # Build
FROM deps AS builder FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack RUN mkdir -p /home/node/.cache/node/corepack && \
corepack prepare pnpm@9.15.4 --activate
COPY packages/ packages/ COPY packages/ packages/
COPY apps/api/ apps/api/ COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \ RUN pnpm --filter @groombook/types build && \
@@ -21,7 +22,9 @@ RUN pnpm --filter @groombook/types build && \
# Runtime # Runtime
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && \
mkdir -p /home/node/.cache/node/corepack && \
corepack prepare pnpm@9.15.4 --activate
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
+1 -2
View File
@@ -24,14 +24,13 @@
"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.1.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@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",
-131
View File
@@ -1,131 +0,0 @@
import { vi } from "vitest";
export const mockRows: Record<string, unknown[]> = {};
export function resetMock() {
Object.keys(mockRows).forEach((key) => {
mockRows[key] = [];
});
}
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (
prop === "where" ||
prop === "orderBy" ||
prop === "limit" ||
prop === "leftJoin" ||
prop === "rightJoin" ||
prop === "innerJoin"
) {
return () => chain;
}
return target[prop as keyof typeof target];
},
});
return chain;
}
function createTableProxy(tableName: string): unknown {
return new Proxy(
{ _name: tableName },
{
get: (target, prop) =>
prop === "_name" ? tableName : { table: tableName, column: prop },
}
);
}
const tables = [
"user",
"session",
"account",
"verification",
"clients",
"pets",
"services",
"staff",
"recurringSeries",
"appointmentGroups",
"appointments",
"invoices",
"invoiceLineItems",
"invoiceTipSplits",
"refunds",
"reminderLogs",
"impersonationSessions",
"impersonationAuditLogs",
"conversations",
"messages",
"messageAttachments",
"messageConsentEvents",
"businessSettings",
"groomingVisitLogs",
"waitlistEntries",
"authProviderConfig",
] as const;
type TableName = (typeof tables)[number];
const tableProxies: Record<TableName, unknown> = {} as Record<TableName, unknown>;
tables.forEach((table) => {
tableProxies[table] = createTableProxy(table);
});
vi.mock("@groombook/db", () => ({
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
const tableName = table._name as TableName;
const rows = mockRows[tableName] || [];
return makeChainable(rows);
},
}),
insert: () => ({
values: (vals: Record<string, unknown>) => ({
returning: () => [{ ...vals, id: "mock-id" }],
}),
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => [{ ...vals, id: "mock-id" }],
}),
}),
}),
delete: () => ({
where: () => ({
returning: () => [{ id: "mock-id" }],
}),
}),
}),
...tableProxies,
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
ne: vi.fn(),
gt: vi.fn(),
gte: vi.fn(),
lt: vi.fn(),
lte: vi.fn(),
inArray: vi.fn(),
isNull: vi.fn(),
ilike: vi.fn(),
sql: vi.fn(),
exists: vi.fn(),
desc: vi.fn(),
asc: vi.fn(),
encryptSecret: vi.fn(),
decryptSecret: vi.fn(),
appointmentStatusEnum: ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"],
staffRoleEnum: ["groomer", "receptionist", "manager"],
invoiceStatusEnum: ["draft", "pending", "paid", "void"],
paymentMethodEnum: ["cash", "card", "check", "other"],
clientStatusEnum: ["active", "disabled"],
messagingChannelEnum: ["sms", "mms"],
messageDirectionEnum: ["inbound", "outbound"],
messageStatusEnum: ["queued", "sent", "delivered", "failed"],
}));
+2 -19
View File
@@ -41,14 +41,12 @@ let selectRows: Record<string, unknown>[] = [];
let selectSessionRow: Record<string, unknown> | null = null; let selectSessionRow: Record<string, unknown> | null = null;
let insertedValues: Record<string, unknown>[] = []; let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = []; let updatedValues: Record<string, unknown>[] = [];
let insertedAuditLogs: Record<string, unknown>[] = [];
function resetMock() { function resetMock() {
selectRows = []; selectRows = [];
selectSessionRow = null; selectSessionRow = null;
insertedValues = []; insertedValues = [];
updatedValues = []; updatedValues = [];
insertedAuditLogs = [];
} }
vi.mock("@groombook/db", () => { vi.mock("@groombook/db", () => {
@@ -96,11 +94,6 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
); );
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
);
return { return {
getDb: () => ({ getDb: () => ({
select: () => ({ select: () => ({
@@ -116,18 +109,9 @@ vi.mock("@groombook/db", () => {
}), }),
insert: () => ({ insert: () => ({
values: (vals: Record<string, unknown>) => { values: (vals: Record<string, unknown>) => {
// Only count waitlist entry inserts, not audit log inserts from portalAudit middleware insertedValues.push(vals);
if (vals.petId || vals.serviceId || vals.status !== undefined) {
insertedValues.push(vals);
}
return { return {
returning: () => { returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
if (vals.sessionId && !vals.petId) {
insertedAuditLogs.push(vals);
return [{ ...vals, id: "audit-log-uuid", createdAt: new Date() }];
}
return [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }];
},
}; };
}, },
}), }),
@@ -155,7 +139,6 @@ vi.mock("@groombook/db", () => {
}), }),
waitlistEntries, waitlistEntries,
impersonationSessions, impersonationSessions,
impersonationAuditLogs,
clients, clients,
pets, pets,
services, services,
+10 -25
View File
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js"; import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js"; import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js"; import { searchRouter } from "./routes/search.js";
import { getObject } from "./lib/s3.js"; import { getPresignedGetUrl } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js"; import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js"; import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db"; import { getDb, businessSettings, eq, staff } from "@groombook/db";
@@ -29,7 +29,6 @@ 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();
@@ -70,9 +69,6 @@ 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);
@@ -130,31 +126,20 @@ function validateLogoMagicBytes(
} }
} }
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
app.get("/api/branding/logo", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
// Public branding endpoint — no auth required, returns business name/colors/logo // Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => { app.get("/api/branding", async (c) => {
const db = getDb(); const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1); const [row] = await db.select().from(businessSettings).limit(1);
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null }; const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
// Return the public proxy path so browser never sees a raw S3 URL let logoUrl: string | null = null;
const logoUrl = settings.logoKey ? "/api/branding/logo" : null; if (settings.logoKey) {
try {
logoUrl = await getPresignedGetUrl(settings.logoKey);
} catch {
// If S3 URL generation fails, fall back to legacy base64
}
}
// Defensive: validate magic bytes to prevent MIME type confusion attacks // Defensive: validate magic bytes to prevent MIME type confusion attacks
// via the legacy base64 logo fields // via the legacy base64 logo fields
@@ -217,7 +202,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
api.use("/admin/*", requireRoleOrSuperUser("manager")); api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/admin/settings/*", requireSuperUser()); api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager")); api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer")); api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager")); api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist // Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
+4 -16
View File
@@ -93,15 +93,9 @@ export async function initAuth(): Promise<void> {
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: { rateLimit: {
enabled: true, enabled: true,
max: 100, max: 10,
window: 10, window: 60,
storage: "memory", 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,
},
}, },
plugins: [ plugins: [
genericOAuth({ genericOAuth({
@@ -246,15 +240,9 @@ export async function initAuth(): Promise<void> {
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: { rateLimit: {
enabled: true, enabled: true,
max: 100, max: 10,
window: 10, window: 60,
storage: "memory", 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,
},
}, },
account: { account: {
storeStateStrategy: "cookie" as const, storeStateStrategy: "cookie" as const,
-38
View File
@@ -67,41 +67,3 @@ export async function deleteObject(key: string): Promise<void> {
}) })
); );
} }
/** Read an object from S3 and return its body buffer and content type. */
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
const client = getS3Client();
const response = await client.send(
new GetObjectCommand({
Bucket: getBucket(),
Key: key,
})
);
const chunks: Uint8Array[] = [];
// response.Body is a Readable stream; collect chunks into a buffer
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const contentType = response.ContentType ?? "application/octet-stream";
return { body, contentType };
}
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
export async function putObject(
key: string,
body: Buffer | Uint8Array | string,
contentType: string,
contentLength: number
): Promise<void> {
const client = getS3Client();
await client.send(
new PutObjectCommand({
Bucket: getBucket(),
Key: key,
Body: body,
ContentType: contentType,
ContentLength: contentLength,
})
);
}
+60 -146
View File
@@ -14,19 +14,10 @@ import {
clients, clients,
sql, sql,
} from "@groombook/db"; } from "@groombook/db";
import type { AppEnv, StaffRole } from "../middleware/rbac.js"; import type { AppEnv } from "../middleware/rbac.js";
import { requireRole } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>(); export const invoicesRouter = new Hono<AppEnv>();
// Convert Zod validation errors from 422 to 400
invoicesRouter.onError((err, c) => {
if (err instanceof z.ZodError) {
return c.json({ error: "Validation failed", issues: err.issues }, 400);
}
throw err;
});
const createInvoiceSchema = z.object({ const createInvoiceSchema = z.object({
appointmentId: z.string().uuid().optional(), appointmentId: z.string().uuid().optional(),
clientId: z.string().uuid(), clientId: z.string().uuid(),
@@ -102,8 +93,6 @@ 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,
}) })
@@ -131,17 +120,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]); ]);
let cardLast4: string | null = null; return c.json({ ...invoice, lineItems, tipSplits });
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)
@@ -362,23 +341,30 @@ invoicesRouter.patch(
} }
} }
const tipCents = body.tipCents ?? current.tipCents; // Tip split validation when marking as paid with a tip
const effectiveTipCents = body.tipCents ?? current.tipCents;
// Validate tip splits when marking invoice as paid if (body.status === "paid" && effectiveTipCents > 0) {
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) { if (body.tipSplits !== undefined) {
if (body.tipSplits.length === 0) { if (body.tipSplits.length === 0) {
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400); return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
} }
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0); const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
if (Math.abs(totalPct - 100) > 0.01) { if (totalBps !== 10000) {
return c.json({ error: "Tip split percentages must sum to 100%" }, 400); return c.json({ error: "Split percentages must sum to 100" }, 422);
}
} else {
const existingSplits = await db
.select({ id: invoiceTipSplits.id })
.from(invoiceTipSplits)
.where(eq(invoiceTipSplits.invoiceId, id));
if (existingSplits.length === 0) {
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
}
} }
} }
// Destructure tipSplits out — it belongs to a separate table, not the invoices column const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body;
// eslint-disable-next-line @typescript-eslint/no-unused-vars const update: Record<string, unknown> = { ...bodyWithoutSplits, updatedAt: new Date() };
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
// Auto-set paidAt when marking as paid // Auto-set paidAt when marking as paid
if (body.status === "paid" && !body.paidAt && !current.paidAt) { if (body.status === "paid" && !body.paidAt && !current.paidAt) {
@@ -392,50 +378,54 @@ invoicesRouter.patch(
update.totalCents = current.subtotalCents + newTaxCents + newTipCents; update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
} }
// Wrap tip split persistence and invoice update in a single atomic transaction const [updated] = await db.transaction(async (tx) => {
const [updated, lineItems] = await db.transaction(async (tx) => { const [upd] = await tx
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
const splits = body.tipSplits;
if (splits.length > 0) {
let remaining = tipCents;
const rows = splits.map((s, i) => {
const isLast = i === splits.length - 1;
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
if (!isLast) remaining -= shareCents;
return {
invoiceId: id,
staffId: s.staffId,
staffName: s.staffName,
sharePct: s.sharePct.toFixed(2),
shareCents,
};
});
await tx.insert(invoiceTipSplits).values(rows);
}
}
const [updatedInvoice] = await tx
.update(invoices) .update(invoices)
.set(update) .set(update)
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
.returning(); .returning();
const lineItems = await tx // Atomically save tip splits when marking paid with provided splits
.select() if (
.from(invoiceLineItems) body.status === "paid" &&
.where(eq(invoiceLineItems.invoiceId, id)); effectiveTipCents > 0 &&
incomingTipSplits !== undefined &&
incomingTipSplits.length > 0
) {
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
return [updatedInvoice, lineItems]; let remaining = effectiveTipCents;
const rows = incomingTipSplits.map((s, i) => {
const isLast = i === incomingTipSplits.length - 1;
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * effectiveTipCents);
if (!isLast) remaining -= shareCents;
return {
invoiceId: id,
staffId: s.staffId,
staffName: s.staffName,
sharePct: s.sharePct.toFixed(2),
shareCents,
};
});
await tx.insert(invoiceTipSplits).values(rows);
}
return [upd];
}); });
const lineItems = await db
.select()
.from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, id));
return c.json({ ...updated, lineItems }); return c.json({ ...updated, lineItems });
} }
); );
// ─── Refund ─────────────────────────────────────────────────────────────────── // ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund, getPaymentIntentDetails } from "../services/payment.js"; import { processRefund } from "../services/payment.js";
const refundSchema = z.object({ const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(), amountCents: z.number().int().nonnegative().optional(),
@@ -462,7 +452,7 @@ invoicesRouter.post(
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) { if (!invoice.stripePaymentIntentId) {
return c.json({ error: "Invoice has no Stripe payment intent" }, 422); 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) => {
@@ -478,91 +468,15 @@ invoicesRouter.post(
const result = await processRefund(id, body.amountCents); const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500); if (!result) return c.json({ error: "Refund failed" }, 500);
const refundId = result.refundId;
await tx.insert(refunds).values({ await tx.insert(refunds).values({
invoiceId: id, invoiceId: id,
stripeRefundId: refundId, stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null, idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null, amountCents: body.amountCents ?? null,
}); });
return c.json({ refundId }); return c.json({ refundId: result.refundId });
}); });
} }
); );
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", requireRole("manager" as StaffRole), async (c) => {
try {
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "pending"));
const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db
.select({
method: invoices.paymentMethod,
total: sql<number>`count(*)`,
})
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod);
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
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)
invoicesRouter.get("/:id/stripe-details", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
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({
stripePaymentIntentId: invoice.stripePaymentIntentId,
stripeRefundId: invoice.stripeRefundId,
cardLast4,
paymentStatus,
});
});
+2 -10
View File
@@ -213,11 +213,7 @@ petsRouter.post(
// Delete the previous photo from storage to avoid orphaned objects // Delete the previous photo from storage to avoid orphaned objects
if (pet.photoKey) { if (pet.photoKey) {
try { await deleteObject(pet.photoKey);
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
}
} }
const [row] = await db const [row] = await db
@@ -244,11 +240,7 @@ petsRouter.delete("/:petId/photo", async (c) => {
if (!pet) return c.json({ error: "Pet not found" }, 404); if (!pet) return c.json({ error: "Pet not found" }, 404);
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404); if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
try { await deleteObject(pet.photoKey);
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
}
await db await db
.update(pets) .update(pets)
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() }) .set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
+75 -65
View File
@@ -9,68 +9,6 @@ import type { PortalEnv } from "../middleware/portalSession.js";
export const portalRouter = new Hono<PortalEnv>(); export const portalRouter = new Hono<PortalEnv>();
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
// the impersonation session and has no X-Impersonation-Session-Id header yet.
const devSessionSchema = z.object({
clientId: z.string().uuid(),
});
portalRouter.post(
"/dev-session",
zValidator("json", devSessionSchema),
async (c) => {
if (process.env.AUTH_DISABLED !== "true") {
return c.json({ error: "Not available when auth is enabled" }, 403);
}
const db = getDb();
const body = c.req.valid("json");
const [client] = await db
.select()
.from(clients)
.where(eq(clients.id, body.clientId))
.limit(1);
if (!client) {
return c.json({ error: "Client not found" }, 404);
}
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.id, DEMO_STAFF_ID))
.limit(1);
if (!demoStaff) {
const [firstStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.active, true))
.limit(1);
if (!firstStaff) {
return c.json({ error: "No staff records found. Run the database seed." }, 500);
}
staffId = firstStaff.id;
}
const [session] = await db
.insert(impersonationSessions)
.values({
staffId,
clientId: body.clientId,
reason: "dev-mode-client-portal",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})
.returning();
return c.json(session, 201);
}
);
// Apply middleware to all portal routes // Apply middleware to all portal routes
portalRouter.use("/*", validatePortalSession, portalAudit); portalRouter.use("/*", validatePortalSession, portalAudit);
@@ -121,12 +59,15 @@ portalRouter.get("/appointments", async (c) => {
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
const serviceIds = allAppts.map(a => a.serviceId).filter((id): id is string => id !== null);
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({ const appts = allAppts.map(a => ({
id: a.id, id: a.id,
@@ -137,7 +78,7 @@ portalRouter.get("/appointments", async (c) => {
customerNotes: a.customerNotes, customerNotes: a.customerNotes,
notes: a.notes, notes: a.notes,
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
service: a.serviceId ? { id: a.serviceId } : null, service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name, duration: serviceMap[a.serviceId]?.durationMinutes, price: serviceMap[a.serviceId]?.basePriceCents } : null,
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
})); }));
@@ -149,7 +90,7 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId"); const clientId = c.get("portalClientId");
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes }))); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
}); });
portalRouter.get("/invoices", async (c) => { portalRouter.get("/invoices", async (c) => {
@@ -518,4 +459,73 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
const ok = await detachPaymentMethod(paymentMethodId); const ok = await detachPaymentMethod(paymentMethodId);
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500); if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
// ─── Dev-mode session creation ──────────────────────────────────────────────
// Allows the dev login selector to vend an impersonation session for a client
// without requiring manager auth. Only available when AUTH_DISABLED=true.
const devSessionSchema = z.object({
clientId: z.string().uuid(),
});
portalRouter.post(
"/dev-session",
zValidator("json", devSessionSchema),
async (c) => {
if (process.env.AUTH_DISABLED !== "true") {
return c.json({ error: "Not available when auth is enabled" }, 403);
}
const db = getDb();
const body = c.req.valid("json");
// Verify client exists
const [client] = await db
.select()
.from(clients)
.where(eq(clients.id, body.clientId))
.limit(1);
if (!client) {
return c.json({ error: "Client not found" }, 404);
}
// Find a staff record to associate with the dev impersonation session.
// Use the demo-manager if it exists (created by seed with known ID),
// otherwise fall back to the first active staff record.
// This avoids hardcoding a UUID that may not exist in all environments.
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.id, DEMO_STAFF_ID))
.limit(1);
if (!demoStaff) {
// Fall back to any active staff member
const [firstStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.active, true))
.limit(1);
if (!firstStaff) {
return c.json({ error: "No staff records found. Run the database seed." }, 500);
}
staffId = firstStaff.id;
}
const [session] = await db
.insert(impersonationSessions)
.values({
staffId,
clientId: body.clientId,
reason: "dev-mode-client-portal",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
})
.returning();
return c.json(session, 201);
}
);
+4 -82
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db"; import { eq, getDb, businessSettings } from "@groombook/db";
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js"; import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js"; import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono(); export const settingsRouter = new Hono();
@@ -100,77 +100,6 @@ settingsRouter.post(
} }
); );
/**
* POST /api/admin/settings/logo/upload
* Proxy upload through the API server to avoid mixed-content issues with
* pre-signed URLs that use the internal HTTP endpoint. The file is uploaded
* directly to S3 from the server using the internal endpoint.
*/
settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => {
const db = getDb();
// Parse multipart form data (file field)
const body = await c.req.parseBody({ all: true });
const file = body["file"];
if (!file || !(file instanceof File)) {
return c.json({ error: "No file provided" }, 400);
}
const contentType = file.type;
if (!ALLOWED_LOGO_TYPES.has(contentType)) {
return c.json(
{
error:
"contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
},
400
);
}
const fileSizeBytes = file.size;
if (fileSizeBytes > MAX_LOGO_SIZE) {
return c.json({ error: "File must not exceed 512 KB" }, 400);
}
const rows = await db.select().from(businessSettings).limit(1);
if (!rows[0]) {
return c.json({ error: "Settings not found" }, 404);
}
const settingsId = rows[0].id;
const ext = contentType.split("/")[1] ?? "png";
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
// Read file into buffer and upload directly to S3 (bypasses pre-signed URL)
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await putObject(key, buffer, contentType, fileSizeBytes);
// Delete previous S3 object if any
if (rows[0].logoKey) {
await deleteObject(rows[0].logoKey);
}
// Update database with new logo key
const [updated] = await db
.update(businessSettings)
.set({
logoKey: key,
logoBase64: null,
logoMimeType: null,
updatedAt: new Date(),
})
.where(eq(businessSettings.id, settingsId))
.returning();
if (!updated) {
return c.json({ error: "Settings not found" }, 404);
}
return c.json({ ok: true, logoKey: updated.logoKey });
});
/** /**
* POST /api/admin/settings/logo/confirm * POST /api/admin/settings/logo/confirm
* Called after the client has successfully uploaded to the presigned URL. * Called after the client has successfully uploaded to the presigned URL.
@@ -215,8 +144,7 @@ settingsRouter.post(
/** /**
* GET /api/admin/settings/logo * GET /api/admin/settings/logo
* Proxies the logo from S3 so the browser never sees an S3 URL. * Returns a presigned GET URL for the logo.
* Returns the image bytes with proper Content-Type.
*/ */
settingsRouter.get("/logo", async (c) => { settingsRouter.get("/logo", async (c) => {
const db = getDb(); const db = getDb();
@@ -225,14 +153,8 @@ settingsRouter.get("/logo", async (c) => {
if (!row) return c.json({ error: "Settings not found" }, 404); if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const { body, contentType } = await getObject(row.logoKey); const url = await getPresignedGetUrl(row.logoKey);
return new Response(Buffer.from(body), { return c.json({ url, logoKey: row.logoKey });
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
}); });
/** /**
+1 -1
View File
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } { function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
const entry = rateLimitMap.get(ip);
const now = Date.now(); const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) { if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 }; return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
-59
View File
@@ -1,59 +0,0 @@
import { Hono } from "hono";
import { validateTelnyxSignature } from "../../services/sms.js";
import {
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
} from "../../services/messaging/inbound.js";
export const telnyxWebhooksRouter = new Hono();
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)) {
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,233 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { detectKeyword } from "../consent.js";
vi.mock("@groombook/db", () => ({
db: {
insert: vi.fn(),
update: vi.fn(),
select: vi.fn(),
},
clients: {},
messageConsentEvents: {},
businessSettings: {},
eq: vi.fn(),
}));
const { handleConsentKeyword } = await import("../consent.js");
const { db } = await import("@groombook/db");
describe("detectKeyword", () => {
it.each([
["STOP", "opt_out"],
["STOPALL", "opt_out"],
["UNSUBSCRIBE", "opt_out"],
["CANCEL", "opt_out"],
["END", "opt_out"],
["QUIT", "opt_out"],
])("opt-out keyword %s → opt_out", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["START", "opt_in"],
["UNSTOP", "opt_in"],
["YES", "opt_in"],
["SUBSCRIBE", "opt_in"],
])("opt-in keyword %s → opt_in", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["HELP", "help"],
["INFO", "help"],
])("help keyword %s → help", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it("is case insensitive", () => {
expect(detectKeyword("stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" });
});
it("trims whitespace", () => {
expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" });
expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" });
});
it("returns null for non-keyword messages", () => {
expect(detectKeyword("hello")).toBeNull();
expect(detectKeyword("STOP IT")).toBeNull();
expect(detectKeyword("help me")).toBeNull();
});
});
describe("handleConsentKeyword", () => {
beforeEach(() => {
vi.clearAllMocks();
db.insert.mockReturnValue({
values: vi.fn().mockResolvedValue([{ id: "event-1" }]),
} as any);
db.update.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
} as any);
});
const baseOpts = {
clientId: "client-1",
businessId: "biz-1",
db: db as unknown as typeof import("@groombook/db").db,
};
describe("opt_out", () => {
it("inserts consent event with sms_keyword source", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(db.insert).toHaveBeenCalledOnce();
});
it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(db.update).toHaveBeenCalled();
});
it("is idempotent — second opt-out logs event but skips client update", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(db.update).not.toHaveBeenCalled();
});
it("returns unsubscribe reply text", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(result.replyText).toBe(
"You have been unsubscribed and will no longer receive messages. Reply START to resubscribe."
);
});
});
describe("opt_in", () => {
it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(db.update).toHaveBeenCalled();
});
it("clears smsOptOutDate on opt-in after opt-out", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(db.update).toHaveBeenCalled();
});
it("is idempotent — second opt-in skips client update", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(db.update).not.toHaveBeenCalled();
});
it("returns resubscribe reply text", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(result.replyText).toBe(
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply."
);
});
});
describe("help", () => {
it("does not call update — opt-in state unchanged", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingHelpReply: null }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
expect(db.update).not.toHaveBeenCalled();
expect(result.replyText).toBe(
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."
);
});
it("uses business messagingHelpReply when configured", async () => {
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingHelpReply: "Custom help text." }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
expect(result.replyText).toBe("Custom help text.");
});
});
});
@@ -1,313 +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: "" },
clients: { id: "", name: "", email: "", phone: "", status: "" },
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", () => {
beforeEach(() => {
vi.resetModules();
});
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/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/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");
});
it("creates placeholder client for unknown phone then creates conversation", 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([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-3");
expect(result.clientId).toBe("client-3");
});
});
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();
mockDb.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}));
});
it("returns 404 when no business owns the to number", async () => {
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([{ id: "biz-1" }]),
}),
}),
})
.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: "client-new" }]),
}),
})
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]),
}),
});
mockDb.update.mockReturnValueOnce({
set: vi.fn().mockReturnValue({
where: 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();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
});
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({
returning: vi.fn().mockReturnValue([{ id: "msg-1" }]),
}),
}),
});
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.js");
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,83 +0,0 @@
import { db, clients, messageConsentEvents, businessSettings, eq } from "@groombook/db";
export type KeywordKind = "opt_in" | "opt_out" | "help";
const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]);
const HELP_KEYWORDS = new Set(["HELP", "INFO"]);
export function detectKeyword(body: string): { kind: KeywordKind } | null {
const normalized = body.trim().toUpperCase();
if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" };
if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" };
if (HELP_KEYWORDS.has(normalized)) return { kind: "help" };
return null;
}
export async function handleConsentKeyword(opts: {
clientId: string;
businessId: string;
kind: KeywordKind;
db: typeof import("@groombook/db").db;
}): Promise<{ replyText: string }> {
const { clientId, businessId, kind, db: database } = opts;
await database.insert(messageConsentEvents).values({
clientId,
businessId,
kind,
source: "sms_keyword",
});
if (kind === "opt_out") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== false) {
await database
.update(clients)
.set({ smsOptIn: false, smsOptOutDate: new Date() })
.where(eq(clients.id, clientId));
}
return {
replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.",
};
}
if (kind === "opt_in") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== true) {
await database
.update(clients)
.set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null })
.where(eq(clients.id, clientId));
}
return {
replyText:
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.",
};
}
// kind === "help"
const [settings] = await database
.select({ messagingHelpReply: businessSettings.messagingHelpReply })
.from(businessSettings)
.where(eq(businessSettings.id, businessId))
.limit(1);
const replyText =
settings?.messagingHelpReply ??
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly.";
return { replyText };
}
-218
View File
@@ -1,218 +0,0 @@
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
import { detectKeyword, handleConsentKeyword } from "./consent.js";
import { sendMessage } from "./outbound.js";
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 [existingClient] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.phone, clientPhone))
.limit(1);
const clientId = existingClient?.id ?? uuidv4();
if (!existingClient) {
await db.insert(clients).values({
id: clientId,
name: clientPhone,
email: `sms-${uuidv4()}@placeholder.local`,
phone: clientPhone,
status: "active",
});
}
const [created] = await db
.insert(conversations)
.values({
id: crypto.randomUUID(),
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 };
}
try {
const [inserted] = await db
.insert(messages)
.values({
id: crypto.randomUUID(),
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 };
} catch (err) {
if (err instanceof Error && err.message.includes("unique")) {
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 };
}
throw err;
}
}
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"
);
const keyword = detectKeyword(message.body ?? "");
if (keyword) {
const { replyText } = await handleConsentKeyword({
clientId,
businessId,
kind: keyword.kind,
db: getDb(),
});
await sendMessage({
businessId,
clientId,
body: replyText,
staffId: undefined,
});
}
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") {
newStatus = "delivered";
}
if (newStatus !== existing.status) {
await db
.update(messages)
.set({ status: newStatus, deliveredAt: new Date() })
.where(eq(messages.id, existing.id));
}
return { messageId: existing.id, newStatus };
}
-159
View File
@@ -1,159 +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,
})
.where(eq(messages.id, queuedMessage.id));
await db
.update(conversations)
.set({ lastMessageAt: 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,
})
.where(eq(messages.id, queuedMessage.id));
throw err;
}
}
-16
View File
@@ -162,19 +162,3 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
return { clientSecret: setupIntent.client_secret! }; return { clientSecret: setupIntent.client_secret! };
} }
export async function getPaymentIntentDetails(
paymentIntentId: string
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
const cardLast4 = pi.payment_method
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
: null;
return {
cardLast4,
paymentStatus: pi.status ?? null,
};
}
+27 -30
View File
@@ -32,35 +32,6 @@ function isE164(phone: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(phone); return /^\+[1-9]\d{7,14}$/.test(phone);
} }
export function validateTelnyxSignature(
rawBody: string,
signature: string | undefined | 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;
}
}
export async function sendSms( export async function sendSms(
to: string, to: string,
body: string, body: string,
@@ -103,7 +74,33 @@ export class TelnyxProvider implements SmsProvider {
} }
validateWebhookSignature(req: Request): boolean { validateWebhookSignature(req: Request): boolean {
return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature")); const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
const signature = req.headers.get("telnyx-signature");
if (!signature) return false;
const payload = JSON.stringify(req.body);
try {
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(payload).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;
}
} }
} }
-49
View File
@@ -63,52 +63,3 @@ test("clicking a client shows their details", async ({ page }) => {
// Email appears in both the list row and the detail panel once selected // Email appears in both the list row and the detail panel once selected
await expect(page.getByText("alice@example.com")).toHaveCount(2); await expect(page.getByText("alice@example.com")).toHaveCount(2);
}); });
test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => {
// Mock individual client fetch for direct navigation
await page.route("/api/clients/client-1", (route) =>
route.fulfill({ json: MOCK_CLIENTS[0] })
);
// Mock pets for this client
await page.route("/api/pets**", (route) =>
route.fulfill({ json: [] })
);
await page.goto("/admin/clients/client-1");
// Client name must be visible without any clicking
await expect(page.getByText("Alice Johnson")).toBeVisible();
// Should show back to list link
await expect(page.getByText("← Back to list")).toBeVisible();
});
test("direct URL navigation shows loading then client", async ({ page }) => {
let resolvePets: (value: unknown) => void;
const petsPromise = new Promise((resolve) => { resolvePets = resolve; });
await page.route("/api/clients/client-1", (route) =>
route.fulfill({ json: MOCK_CLIENTS[0] })
);
await page.route("/api/pets**", async (route) => {
await petsPromise;
await route.fulfill({ json: [] });
});
const navigationPromise = page.goto("/admin/clients/client-1");
// Should show loading state briefly
await expect(page.getByText("Loading client…")).toBeVisible();
// Resolve pets and wait for navigation
resolvePets!();
await navigationPromise;
// After data loads, client name is shown
await expect(page.getByText("Alice Johnson")).toBeVisible();
});
test("direct URL navigation shows error state on failure", async ({ page }) => {
await page.route("/api/clients/nonexistent", (route) =>
route.fulfill({ status: 404, json: { error: "Client not found" } })
);
await page.goto("/admin/clients/nonexistent");
await expect(page.getByText(/client not found/i)).toBeVisible();
await expect(page.getByText("← Back to clients")).toBeVisible();
});
-10
View File
@@ -44,16 +44,6 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 }, json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
}); });
} }
if (url.includes("/api/invoices/stats/summary")) {
return route.fulfill({
json: {
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
},
});
}
if (url.includes("/api/invoices")) { if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } }); return route.fulfill({ json: { data: [], total: 0 } });
} }
+3 -9
View File
@@ -72,15 +72,9 @@ test.describe("Portal Data Integrity", () => {
}); });
test("billing section renders without JS errors", async ({ page }) => { test("billing section renders without JS errors", async ({ page }) => {
// Mock portal billing endpoints // Mock billing endpoint
await page.route("**/api/portal/config**", (route) => await page.route("**/api/billing**", (route) =>
route.fulfill({ json: { stripePublishableKey: "" } }) route.fulfill({ json: { invoices: [], balanceCents: 0 } })
);
await page.route("**/api/portal/invoices**", (route) =>
route.fulfill({ json: [] })
);
await page.route("**/api/portal/payment-methods**", (route) =>
route.fulfill({ json: [] })
); );
const consoleErrors: string[] = []; const consoleErrors: string[] = [];
-2
View File
@@ -2,7 +2,6 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js"; import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js"; import { ClientsPage } from "./pages/Clients.js";
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
import { ServicesPage } from "./pages/Services.js"; import { ServicesPage } from "./pages/Services.js";
import { StaffPage } from "./pages/Staff.js"; import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js"; import { InvoicesPage } from "./pages/Invoices.js";
@@ -297,7 +296,6 @@ function AdminLayout() {
<Routes> <Routes>
<Route path="/" element={<AppointmentsPage />} /> <Route path="/" element={<AppointmentsPage />} />
<Route path="/clients" element={<ClientsPage />} /> <Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
<Route path="/services" element={<ServicesPage />} /> <Route path="/services" element={<ServicesPage />} />
<Route path="/staff" element={<StaffPage />} /> <Route path="/staff" element={<StaffPage />} />
<Route path="/invoices" element={<InvoicesPage />} /> <Route path="/invoices" element={<InvoicesPage />} />
-10
View File
@@ -82,13 +82,3 @@ input:focus, select:focus, textarea:focus {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #94a3b8; background: #94a3b8;
} }
/* ─── Scrollbar hide utility ─── */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
-26
View File
@@ -112,17 +112,9 @@ 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();
@@ -322,24 +314,6 @@ 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>
-236
View File
@@ -1,236 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
export function ClientDetailPage() {
const { clientId } = useParams<{ clientId: string }>();
const [client, setClient] = useState<Client | null>(null);
const [pets, setPets] = useState<Pet[]>([]);
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
const handlePhotoUploaded = useCallback((petId: string) => {
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
}, []);
useEffect(() => {
if (!clientId) {
setError("No client ID provided");
setLoading(false);
return;
}
async function load() {
const id = clientId!;
setLoading(true);
setError(null);
try {
const [clientRes, petsRes] = await Promise.all([
fetch(`/api/clients/${encodeURIComponent(id)}`),
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
]);
if (!clientRes.ok) {
const err = await clientRes.json().catch(() => ({})) as { error?: string };
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
}
if (!petsRes.ok) {
throw new Error(`Pets fetch failed: ${petsRes.status}`);
}
setClient(await clientRes.json() as Client);
setPets(await petsRes.json() as Pet[]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load client");
} finally {
setLoading(false);
}
}
void load();
}, [clientId]);
async function loadVisitLogs(petId: string) {
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
if (r.ok) {
const logs = await r.json() as GroomingVisitLog[];
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
}
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
}
if (loading) {
return (
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
Loading client
</div>
);
}
if (error || !client) {
return (
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<div style={{ marginBottom: "1rem" }}>
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}> Back to clients</Link>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
{error ?? "Client not found"}
</div>
</div>
);
}
return (
<div style={{ fontFamily: "system-ui, sans-serif" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
{client.status === "disabled" && (
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</div>
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
{client.notes && (
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
{client.notes}
</div>
)}
</div>
<Link
to="/admin/clients"
style={{
padding: "0.4rem 0.85rem",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "#fff",
color: "#374151",
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
flexShrink: 0,
}}
>
Back to list
</Link>
</div>
{/* Pets */}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
</div>
{pets.length === 0 ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
{/* Photo + header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
</div>
</div>
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
)}
{/* Visit history */}
{(() => {
const logs = visitLogs[p.id];
const loadingLogs = logsLoading[p.id];
return (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
{!logs && !loadingLogs && (
<button
onClick={() => { void loadVisitLogs(p.id); }}
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
Load history
</button>
)}
</div>
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading</div>}
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
{logs && logs.length > 0 && (
<>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</>
)}
</div>
);
})()}
</div>
))}
</div>
)}
</div>
);
}
+11 -54
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef, useId } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
@@ -647,7 +647,8 @@ export function ClientsPage() {
{/* ── Client modal ── */} {/* ── Client modal ── */}
{showClientForm && ( {showClientForm && (
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}> <Modal onClose={() => setShowClientForm(false)}>
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
<form onSubmit={submitClient}> <form onSubmit={submitClient}>
<Field label="Full name"> <Field label="Full name">
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> <input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
@@ -677,7 +678,8 @@ export function ClientsPage() {
{/* ── Pet modal ── */} {/* ── Pet modal ── */}
{showPetForm && ( {showPetForm && (
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}> <Modal onClose={() => setShowPetForm(false)}>
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
<form onSubmit={submitPet}> <form onSubmit={submitPet}>
<Field label="Pet name"> <Field label="Pet name">
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> <input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
@@ -751,7 +753,8 @@ export function ClientsPage() {
{/* ── Visit log modal ── */} {/* ── Visit log modal ── */}
{showLogForm && logPetId && ( {showLogForm && logPetId && (
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}> <Modal onClose={() => setShowLogForm(false)}>
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history</p>} {logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history</p>}
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && ( {visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
<div style={{ marginBottom: "1rem" }}> <div style={{ marginBottom: "1rem" }}>
@@ -814,7 +817,8 @@ export function ClientsPage() {
{/* ── Delete confirmation modal ── */} {/* ── Delete confirmation modal ── */}
{showDeleteConfirm && selectedClient && ( {showDeleteConfirm && selectedClient && (
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}> <Modal onClose={() => setShowDeleteConfirm(false)}>
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
<p style={{ fontSize: 14, color: "#374151" }}> <p style={{ fontSize: 14, color: "#374151" }}>
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone. This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
</p> </p>
@@ -852,60 +856,13 @@ export function ClientsPage() {
// ─── Shared UI ─────────────────────────────────────────────────────────────── // ─── Shared UI ───────────────────────────────────────────────────────────────
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) { function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
const titleId = useId();
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement;
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
const firstFocusable = focusableElements?.[0];
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
if (!modalRef.current) return;
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
};
}, [onClose]);
return ( return (
<div <div
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div <div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
>
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
{children} {children}
</div> </div>
</div> </div>
+9 -198
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState } from "react";
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -173,21 +173,6 @@ 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 [refundType, setRefundType] = useState<"full" | "partial">("full");
const [refundAmount, setRefundAmount] = useState("");
const [refundError, setRefundError] = useState<string | null>(null);
const [refunding, setRefunding] = useState(false);
// Fetch current staff role to determine manager access
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
useEffect(() => {
fetch("/api/staff/me")
.then((r) => r.json())
.then((d) => setStaffMe(d))
.catch(() => setStaffMe(null));
}, []);
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
// Tip split state: array of {staffId, staffName, pct} // Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId const linkedAppt = invoice.appointmentId
@@ -350,19 +335,6 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/> />
{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} />}
{invoice.stripePaymentIntentId && (
<>
{invoice.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
)}
{invoice.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
)}
{invoice.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" />
)}
</>
)}
</div> </div>
{/* ── Tip Distribution ── */} {/* ── Tip Distribution ── */}
@@ -480,92 +452,11 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div> </div>
)} )}
{(invoice.status === "paid" || invoice.status === "void") && ( {(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}> <div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
{invoice.stripeRefundId && ( <button onClick={onClose} style={btnStyle}>Close</button>
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
</div>
)}
<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>
)} )}
</Modal>
{showRefundDialog && (
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
Full refund
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="Amount ($)"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }}
/>
</div>
)}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={async () => {
setRefunding(true);
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" }}
>
{refunding ? "Processing…" : "Process Refund"}
</button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div>
</div>
)}
</Modal>
); );
} }
@@ -606,17 +497,9 @@ export function InvoicesPage() {
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null); const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const LIMIT = 50; const LIMIT = 50;
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
async function loadInvoices(newOffset: number) { async function loadInvoices(newOffset: number) {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) }); const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (statusFilter) params.set("status", statusFilter); if (statusFilter) params.set("status", statusFilter);
@@ -695,34 +578,6 @@ export function InvoicesPage() {
</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" }}>{fmtMoney(paymentStats.revenueThisMonth)}</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" }}>{fmtMoney(paymentStats.outstanding)}</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" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
</div>
{paymentStats.methodBreakdown.length > 0 && (
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
<div style={{ fontSize: 13, color: "#64748b" }}>
{paymentStats.methodBreakdown.map((b) => (
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
))}
</div>
</div>
)}
</div>
)}
{invoiceList.length === 0 ? ( {invoiceList.length === 0 ? (
<p style={{ color: "#6b7280" }}> <p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment. No invoices yet. Create one from a completed appointment.
@@ -827,63 +682,19 @@ export function InvoicesPage() {
// ─── Shared UI helpers ──────────────────────────────────────────────────────── // ─── Shared UI helpers ────────────────────────────────────────────────────────
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement;
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
const firstFocusable = focusableElements?.[0];
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
if (!modalRef.current) return;
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
};
}, [onClose]);
return ( return (
<div <div
role="dialog"
aria-modal="true"
style={{ style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
}} }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div <div style={{
ref={modalRef} background: "#fff", borderRadius: 8, padding: "1.5rem",
style={{ maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
background: "#fff", borderRadius: 8, padding: "1.5rem", boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", }}>
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
{children} {children}
</div> </div>
</div> </div>
+49 -13
View File
@@ -89,14 +89,24 @@ export function SettingsPage() {
fetch("/api/admin/settings") fetch("/api/admin/settings")
.then((r) => r.json()) .then((r) => r.json())
.then(async (data) => { .then(async (data) => {
// The logo is now proxied through the API server so the browser let logoUrl: string | null = null;
// never receives an S3 URL — use the proxy path directly as the src. if (data.logoKey) {
try {
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
logoUrl = logoData.url;
}
} catch {
// ignore
}
}
setForm({ setForm({
businessName: data.businessName ?? "GroomBook", businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f", primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355", accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null, logoKey: data.logoKey ?? null,
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null, logoUrl,
logoBase64: data.logoBase64 ?? null, logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null, logoMimeType: data.logoMimeType ?? null,
}); });
@@ -148,21 +158,47 @@ export function SettingsPage() {
} }
try { try {
// Upload directly through the API server to avoid mixed-content issues // Step 1: Get presigned upload URL
// with pre-signed URLs that use the internal HTTP endpoint const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
const formData = new FormData();
formData.append("file", file);
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
method: "POST", method: "POST",
body: formData, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
}); });
if (!uploadRes.ok) { if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => null); const err = await uploadRes.json().catch(() => null);
throw new Error(err?.error ?? "Failed to upload logo"); throw new Error(err?.error ?? "Failed to get upload URL");
}
const { uploadUrl, key } = await uploadRes.json();
// Step 2: PUT the file directly to S3
const putRes = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!putRes.ok) {
throw new Error("Failed to upload logo to storage");
}
// Step 3: Confirm the upload
const confirmRes = await fetch("/api/admin/settings/logo/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!confirmRes.ok) {
const err = await confirmRes.json().catch(() => null);
throw new Error(err?.error ?? "Failed to confirm logo upload");
}
// Step 4: Fetch the presigned GET URL for display
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null }));
} else {
setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null }));
} }
const { logoKey } = await uploadRes.json();
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
setMessage({ type: "success", text: "Logo uploaded." }); setMessage({ type: "success", text: "Logo uploaded." });
refresh(); refresh();
} catch (err: unknown) { } catch (err: unknown) {
+2 -2
View File
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)} )}
{/* Main Content */} {/* Main Content */}
<main className="flex-1 min-h-screen overflow-hidden"> <main className="flex-1 min-h-screen">
<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">
@@ -340,7 +340,7 @@ export function CustomerPortal() {
</div> </div>
</div> </div>
</div> </div>
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden"> <div className="p-4 md:p-8 max-w-6xl">
{renderSection()} {renderSection()}
</div> </div>
</main> </main>
+22 -1
View File
@@ -123,7 +123,28 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const fetchedAppointments: Appointment[] = data.appointments || data || []; const rawAppointments: Record<string, unknown>[] = data.appointments || data || [];
// Transform API response (startTime) to client format (date + time)
const fetchedAppointments: Appointment[] = rawAppointments.map((a) => {
const start = new Date(a.startTime as string);
const dateStr = start.toISOString().split('T')[0];
const hours = start.getHours();
const minutes = start.getMinutes().toString().padStart(2, '0');
const period = hours >= 12 ? 'PM' : 'AM';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const timeStr = `${hour12}:${minutes} ${period}`;
return {
...a,
date: dateStr,
time: timeStr,
petName: (a.pet as { name?: string })?.name,
serviceName: (a.service as { name?: string })?.name,
groomerName: (a.staff as { name?: string })?.name,
duration: (a.service as { duration?: number })?.duration,
price: (a.service as { price?: number })?.price,
} as Appointment;
});
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt)); const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt)); const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { loadStripe } from "@stripe/stripe-js"; import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
<div className="flex gap-2 overflow-x-auto scrollbar-hide"> <div className="flex gap-2">
{([ {([
{ 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 },
@@ -356,48 +356,6 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false); const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const completeModalRef = useRef<HTMLDivElement>(null);
const paymentModalRef = useRef<HTMLDivElement>(null);
// Focus trap + Escape-to-close for both inline modals
useEffect(() => {
const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current;
if (!modalRef) return;
const previouslyFocused = document.activeElement as HTMLElement;
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
const firstFocusable = focusableElements[0];
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab" || !modalRef) return;
const focusables = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
};
}, [isComplete, onClose]);
const formatCents = (cents: number) => const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
@@ -462,8 +420,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
if (isComplete) { if (isComplete) {
return ( return (
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div ref={completeModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center"> <div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@@ -482,8 +440,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
} }
return ( return (
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div ref={paymentModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6"> <div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2> <h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600"> <button onClick={onClose} className="text-stone-400 hover:text-stone-600">
+18 -1
View File
@@ -116,7 +116,24 @@ export function Dashboard({
const invoicesData = await invoicesRes.json(); const invoicesData = await invoicesRes.json();
const brandingData = await brandingRes.json(); const brandingData = await brandingRes.json();
setAppointments(appointmentsData.appointments || []); const rawAppointments: Record<string, unknown>[] = appointmentsData.appointments || [];
const transformedAppointments = rawAppointments.map((a) => {
const start = new Date(a.startTime as string);
const dateStr = start.toISOString().split('T')[0];
const hours = start.getHours();
const minutes = start.getMinutes().toString().padStart(2, '0');
const period = hours >= 12 ? 'PM' : 'AM';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const timeStr = `${hour12}:${minutes} ${period}`;
return {
...a,
date: dateStr,
time: timeStr,
petName: (a.pet as { name?: string })?.name ?? '',
serviceName: (a.service as { name?: string })?.name ?? '',
};
});
setAppointments(transformedAppointments as Appointment[]);
setPets(petsData.pets || []); setPets(petsData.pets || []);
// Filter for pending invoices only (not "outstanding") // Filter for pending invoices only (not "outstanding")
+5 -4
View File
@@ -27,7 +27,8 @@ interface Appointment {
} }
interface AppointmentsResponse { interface AppointmentsResponse {
appointments: Appointment[]; upcoming: Appointment[];
past: Appointment[];
} }
interface Props { interface Props {
@@ -45,7 +46,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
export function PetProfiles({ sessionId, readOnly }: Props) { export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]); const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] }); const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [selectedPetId, setSelectedPetId] = useState<string>(""); const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info"); const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null); const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -89,7 +90,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]); }, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null; const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date()); const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null; const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) { function handlePetSave(updatedPet: Pet) {
@@ -182,7 +183,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
)} )}
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto scrollbar-hide"> <div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
{([ {([
{ id: "info", label: "Basic Info", icon: PawPrint }, { id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart }, { id: "medical", label: "Medical", icon: Heart },
+19 -2
View File
@@ -44,8 +44,25 @@ export function ReportCards({ sessionId }: Props) {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const allAppointments: Appointment[] = data.appointments || data || []; const rawAppointments: Record<string, unknown>[] = data.appointments || data || [];
const reportCardAppointments = allAppointments.filter( const transformed: Appointment[] = rawAppointments.map((a) => {
const start = new Date(a.startTime as string);
const dateStr = start.toISOString().split('T')[0];
const hours = start.getHours();
const minutes = start.getMinutes().toString().padStart(2, '0');
const period = hours >= 12 ? 'PM' : 'AM';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const timeStr = `${hour12}:${minutes} ${period}`;
return {
...a,
date: dateStr,
time: timeStr,
petName: (a.pet as { name?: string })?.name,
serviceName: (a.service as { name?: string })?.name,
groomerName: (a.staff as { name?: string })?.name,
} as Appointment;
});
const reportCardAppointments = transformed.filter(
(appt) => appt.reportCardId (appt) => appt.reportCardId
); );
setAppointments(reportCardAppointments); setAppointments(reportCardAppointments);
-7
View File
@@ -119,10 +119,3 @@ 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,27 +50,6 @@ 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:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: "" corsOrigin: ""
oidcIssuer: "" oidcIssuer: ""
oidcAudience: groombook oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000" port: "3000"
service: service:
type: ClusterIP type: ClusterIP
-309
View File
@@ -1,309 +0,0 @@
# 10DLC Pilot Tenant Registration Runbook
Authored for GRO-106 Phase 1.
---
## Pre-Flight Checklist
Before starting Telnyx registration, collect the following:
| Item | Details |
|------|---------|
| Legal business name | Exact name on EIN / business registration |
| EIN (Employer Identification Number) | 9-digit IRS format: XX-XXXXXXX |
| Business type | Sole Proprietor / LLC / Corporation |
| Primary contact email | General contact address (postmaster@, info@, etc.) |
| Primary contact phone | Direct line for carrier verification |
| Website URL | Must be live and contain privacy policy |
| Sample message templates | See [Sample Templates](#sample-message-templates) below |
| Messaging use case | Customer Care / Account Notification |
---
## Step 1 — Telnyx Account Requirements
- Active Telnyx account with billing configured.
- Role required: **Admin** or **Super User** to register brands and campaigns.
---
## Step 2 — Brand Registration
### Via Telnyx Console
1. Log in to [Telnyx Portal](https://portal.telnyx.com).
2. Navigate to **Messaging → A2P 10DLC → Brands**.
3. Click **Register Brand**.
4. Fill in:
- **Brand Name**: Legal business name
- **Legal Company Name**: Exact EIN name
- **Company Type**: Select from dropdown
- **EIN**: XX-XXXXXXX
- **Primary Contact**: Name, email, phone
- **Website**: Must be accessible
- **BusinessVertical**: Select appropriate vertical
5. Acknowledge the **Terms of Service**.
6. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/brands \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Your Legal Business Name",
"legal_company_name": "Your Legal Business Name",
"company_type": "llc",
"ein": "XX-XXXXXXX",
"primary_contact": {
"name": "Jane Doe",
"email": "compliance@example.com",
"phone": "+1XXXXXXXXXX"
},
"website": "https://www.example.com",
"business_vertical": "PROFESSIONAL_SERVICES"
}'
```
**Response fields to record:**
- `brand_id` — required for campaign registration
- `brand_score` — affects campaign vetting speed
### Expected Fees
| Fee Type | Amount |
|----------|--------|
| Brand registration fee | ~$0 (no direct fee from Telnyx) |
| Campaign registration fee | ~$15$25 per campaign (Telnyx fee, subject to change) |
| Carrier fees | Passed through from T-Mobile/AT&T/Verizon |
### Expected Approval Window
- **Vetting by Telnyx**: 13 business days after submission.
- **Carrier (T-Mobile/AT&T/Verizon) review**: 25 business days after Telnyx approval.
- Total end-to-end: **38 business days**.
---
## Step 3 — Campaign Registration
### Use Case Selection
- **Primary**: Customer Care
- **Secondary**: Account Notification
### Via Telnyx Console
1. Navigate to **Messaging → A2P 10DLC → Campaigns**.
2. Click **Register Campaign**.
3. Select **Brand** (use the brand registered in Step 2).
4. Fill in:
- **Campaign Name**: e.g., `groombook-pilot-customer-care`
- **Use Case**: Customer Care / Account Notification
- **Sample Messages**: Paste exactly the templates from [Sample Templates](#sample-message-templates) below.
- **Description**: Brief description of messaging program
- **Estimated Volume**: Enter monthly estimate (e.g., 500)
5. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/campaigns \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_id": "YOUR_BRAND_ID",
"name": "groombook-pilot-customer-care",
"use_case": "CUSTOMER_CARE",
"sample_messages": [
"Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out.",
"Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP or call us at {{phone}}."
],
"description": "Appointment reminders and account notifications for grooming clients",
"estimated_monthly_volume": 500
}'
```
**Response fields to record:**
- `campaign_id` — required for messaging profile
- `status` — initially `PENDING`, transitions to `ACTIVE` after carrier approval
### Campaign Vetting — STOP/HELP Language Requirements
Every campaign **must** include compliant STOP/HELP messaging. The following must appear in your sample messages or be included in your terms of service:
- **STOP**: Users can text `STOP` to opt out of all messages.
- **HELP**: Users can text `HELP` to receive contact information.
Example STOP/HELP block:
```
Text STOP to opt out. Text HELP for help. Msg & data rates may apply.
```
---
## Step 4 — Messaging Profile + Phone Number Provisioning
### Create Messaging Profile
1. In Telnyx Portal, navigate to **Messaging → Messaging Profiles**.
2. Click **Create Messaging Profile**.
3. Name it (e.g., `groombook-pilot-prod`).
4. Copy the **Messaging Profile ID** (`messaging_profile_id`) — record this in the DB.
### Provision a 10DLC Phone Number
1. Navigate to **Messaging → Phone Numbers**.
2. Search for a number in your desired area code.
3. Confirm the number is 10DLC-capable.
4. Purchase the number.
### Associate Number with Messaging Profile
```bash
# Assign number to messaging profile
curl -X PATCH https://api.telnyx.com/v2/phone_numbers/YOUR_PHONE_NUMBER_ID \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messaging_profile_id": "YOUR_MESSAGING_PROFILE_ID"
}'
```
---
## Step 5 — Record in Database
Once GRO-981 lands, record the following against the business record:
### SQL Path (when GRO-981 is complete)
```sql
UPDATE businesses
SET
messaging_phone_number = '+1XXXXXXXXXX',
telnyx_messaging_profile_id = 'YOUR_MESSAGING_PROFILE_ID',
telnyx_brand_id = 'YOUR_BRAND_ID',
telnyx_campaign_id = 'YOUR_CAMPAIGN_ID',
telnyx_brand_status = 'APPROVED',
telnyx_campaign_status = 'ACTIVE',
updated_at = NOW()
WHERE id = 'pilot_business_id';
```
### Manual Admin Path (before GRO-981)
Until GRO-981 is complete, use the Telnyx Portal to verify and record values manually in your internal ops sheet:
| Field | Value |
|-------|-------|
| `messagingPhoneNumber` | +1XXXXXXXXXX |
| `telnyxMessagingProfileId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxBrandId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxCampaignId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `brandStatus` | APPROVED / PENDING |
| `campaignStatus` | ACTIVE / PENDING |
---
## Sample Message Templates
These must match exactly what your system will send. Vetting reviewers compare templates against actual traffic.
### Transactional Appointment Reminder
```
Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out. Msg & data rates may apply.
```
### Manual Staff Message
```
Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP for assistance or call us at {{phone}}. Msg & data rates may apply.
```
---
## Failure Modes + Retry Guidance
### Vetting Rejection — Brand
| Rejection Reason | Common Fix |
|-----------------|------------|
| Legal name mismatch with EIN | Ensure exact EIN name matches legal company name exactly |
| Website not accessible / missing privacy policy | Add privacy policy page to website before resubmitting |
| Incomplete primary contact | Provide direct phone and real email (no noreply) |
| High-risk business vertical | Contact Telnyx support for pre-screening before resubmitting |
### Campaign Rejection
| Rejection Reason | Common Fix |
|-----------------|------------|
| Sample messages do not match actual traffic | Update sample messages to match exactly what the system sends |
| Missing STOP/HELP language | Add compliant STOP/HELP block to sample messages |
| Volume estimate too low/high | Revise estimate to be realistic |
| Use case mismatch | Re-select use case that matches actual messaging |
### Re-submission
After fixing the rejection reason, re-submit via the same API endpoint. Telnyx will re-run vetting (typically 2448 hours).
---
## Cost Summary
### Telnyx Fees (as of 2026)
| Fee Type | Amount | Notes |
|----------|--------|-------|
| 10DLC number (monthly) | ~$1.00$2.50/number | Varies by type and area code |
| Outbound message | $0.005$0.015/message | Depends on destination carrier |
| Inbound message | Included | No charge for received messages |
| Campaign registration | ~$15$25 one-time | Per campaign, subject to change |
### Carrier Fees (T-Mobile / AT&T / Verizon)
| Carrier | Outbound Fee | Notes |
|---------|-------------|-------|
| T-Mobile | ~$0.005$0.01/message | Varies by message size (segment) |
| AT&T | ~$0.005$0.015/message | Varies by message size (segment) |
| Verizon | ~$0.005$0.01/message | Varies by message size (segment) |
**Note**: Carrier fees are subject to change. Check [Telnyx pricing page](https://telnyx.com/pricing) and carrier fee schedules for current rates.
### Example Monthly Cost (Pilot — 500 messages/month)
| Line Item | Cost |
|-----------|------|
| 1x 10DLC number | ~$2.00 |
| 500 outbound messages | ~$5.00$7.50 |
| Carrier pass-through | ~$2.50$7.50 |
| **Estimated Monthly Total** | **~$9.50$17.00** |
---
## Rollback / De-provisioning
If the pilot tenant must be de-provisioned:
1. Release the phone number: Telnyx Portal → Phone Numbers → Release.
2. Archive the campaign: set status to `INACTIVE` via API or console.
3. Remove DB record: clear `messagingPhoneNumber`, `telnyxMessagingProfileId`, `telnyxCampaignId` fields in the business record.
4. Brand can remain registered (no harm) but will not be used.
---
## Contacts
| Resource | Contact |
|----------|---------|
| Telnyx Support | support@telnyx.com |
| Telnyx Dashboard | portal.telnyx.com |
| Internal Engineering | Raise issue in GRO-106 |
---
_Owner: Engineering · Last updated: 2026-05-04_
-11
View File
@@ -1,11 +0,0 @@
# GroomBook Runbooks
Operational runbooks for GroomBook staff and operators.
| Runbook | Description | Status |
|---------|-------------|--------|
| [10DLC Pilot Registration](./10dlc-pilot-registration.md) | Register a pilot grooming business as an A2P 10DLC brand + campaign on Telnyx | Active |
---
_To add a runbook, create a markdown file in this directory and update this table._
-72
View File
@@ -1,72 +0,0 @@
-- 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;
-14
View File
@@ -204,20 +204,6 @@
"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
} }
] ]
} }
+45 -167
View File
@@ -200,60 +200,51 @@ export const appointmentGroups = pgTable("appointment_groups", {
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });
export const appointments = pgTable( export const appointments = pgTable("appointments", {
"appointments", id: uuid("id").primaryKey().defaultRandom(),
{ clientId: uuid("client_id")
id: uuid("id").primaryKey().defaultRandom(), .notNull()
clientId: uuid("client_id") .references(() => clients.id, { onDelete: "restrict" }),
.notNull() petId: uuid("pet_id")
.references(() => clients.id, { onDelete: "restrict" }), .notNull()
petId: uuid("pet_id") .references(() => pets.id, { onDelete: "restrict" }),
.notNull() serviceId: uuid("service_id")
.references(() => pets.id, { onDelete: "restrict" }), .notNull()
serviceId: uuid("service_id") .references(() => services.id, { onDelete: "restrict" }),
.notNull() staffId: uuid("staff_id").references(() => staff.id, {
.references(() => services.id, { onDelete: "restrict" }), onDelete: "set null",
staffId: uuid("staff_id").references(() => staff.id, { }),
onDelete: "set null", // Optional secondary staff (bather/assistant) for tip-split tracking
}), batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
// Optional secondary staff (bather/assistant) for tip-split tracking onDelete: "set null",
batherStaffId: uuid("bather_staff_id").references(() => staff.id, { }),
onDelete: "set null", status: appointmentStatusEnum("status").notNull().default("scheduled"),
}), startTime: timestamp("start_time").notNull(),
status: appointmentStatusEnum("status").notNull().default("scheduled"), endTime: timestamp("end_time").notNull(),
startTime: timestamp("start_time").notNull(), notes: text("notes"),
endTime: timestamp("end_time").notNull(), // Override price at time of booking (null = use service base price)
notes: text("notes"), priceCents: integer("price_cents"),
// Override price at time of booking (null = use service base price) // Recurring series support
priceCents: integer("price_cents"), seriesId: uuid("series_id").references(() => recurringSeries.id, {
// Recurring series support onDelete: "set null",
seriesId: uuid("series_id").references(() => recurringSeries.id, { }),
onDelete: "set null", seriesIndex: integer("series_index"),
}), // Multi-pet group booking: links this appointment to others in the same visit
seriesIndex: integer("series_index"), groupId: uuid("group_id").references(() => appointmentGroups.id, {
// Multi-pet group booking: links this appointment to others in the same visit onDelete: "set null",
groupId: uuid("group_id").references(() => appointmentGroups.id, { }),
onDelete: "set null", // Customer confirmation/cancellation tracking
}), // Values: "pending" | "confirmed" | "cancelled"
// Customer confirmation/cancellation tracking confirmationStatus: text("confirmation_status").notNull().default("pending"),
// Values: "pending" | "confirmed" | "cancelled" confirmedAt: timestamp("confirmed_at"),
confirmationStatus: text("confirmation_status").notNull().default("pending"), cancelledAt: timestamp("cancelled_at"),
confirmedAt: timestamp("confirmed_at"), // Token for tokenized email confirm/cancel links (no auth required)
cancelledAt: timestamp("cancelled_at"), confirmationToken: text("confirmation_token").unique(),
// Token for tokenized email confirm/cancel links (no auth required) // Customer-provided note visible to groomer (500 char max, editable until appointment starts)
confirmationToken: text("confirmation_token").unique(), customerNotes: text("customer_notes"),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts) createdAt: timestamp("created_at").notNull().defaultNow(),
customerNotes: text("customer_notes"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(), });
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_appointments_client_id").on(t.clientId),
index("idx_appointments_staff_id").on(t.staffId),
index("idx_appointments_start_time").on(t.startTime),
index("idx_appointments_status").on(t.status),
]
);
export const invoices = pgTable( export const invoices = pgTable(
"invoices", "invoices",
@@ -406,117 +397,6 @@ 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"),
},
(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"),
@@ -525,8 +405,6 @@ 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(),
}); });
+1 -4
View File
@@ -978,7 +978,6 @@ 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,
@@ -990,7 +989,6 @@ 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,
}); });
@@ -1094,14 +1092,13 @@ 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, stripePaymentIntentId, notes: null, paidAt, notes: null,
}); });
lineItemBatch.push({ lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1, id: uuid(), invoiceId, description: svc.name, quantity: 1,
-6
View File
@@ -152,16 +152,10 @@ export interface Invoice {
status: InvoiceStatus; status: InvoiceStatus;
paymentMethod: PaymentMethod | null; paymentMethod: PaymentMethod | null;
paidAt: string | null; paidAt: string | null;
stripePaymentIntentId: string | null;
stripeRefundId: string | null;
paymentFailureReason: string | null;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
lineItems?: InvoiceLineItem[]; lineItems?: InvoiceLineItem[];
// Transient fields populated from Stripe API (not stored in DB)
cardLast4?: string | null;
paymentStatus?: string | null;
tipSplits?: InvoiceTipSplit[]; tipSplits?: InvoiceTipSplit[];
} }
-19
View File
@@ -46,9 +46,6 @@ importers:
telnyx: telnyx:
specifier: ^1.23.0 specifier: ^1.23.0
version: 1.27.0 version: 1.27.0
uuid:
specifier: ^11.1.1
version: 11.1.1
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -62,9 +59,6 @@ importers:
'@types/nodemailer': '@types/nodemailer':
specifier: ^6.4.17 specifier: ^6.4.17
version: 6.4.23 version: 6.4.23
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
@@ -2340,9 +2334,6 @@ packages:
'@types/use-sync-external-store@0.0.6': '@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@typescript-eslint/eslint-plugin@8.57.1': '@typescript-eslint/eslint-plugin@8.57.1':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4353,18 +4344,12 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} 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 hasBin: true
uuid@9.0.1: uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} 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 hasBin: true
victory-vendor@37.3.6: victory-vendor@37.3.6:
@@ -6925,8 +6910,6 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {} '@types/use-sync-external-store@0.0.6': {}
'@types/uuid@10.0.0': {}
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -9031,8 +9014,6 @@ snapshots:
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
uuid@11.1.1: {}
uuid@8.3.2: {} uuid@8.3.2: {}
uuid@9.0.1: {} uuid@9.0.1: {}