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_AUDIENCE=groombook
# ── Webhooks ─────────────────────────────────────────────────────────────────
# Telnyx webhook secret for validating inbound message webhooks.
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
-13
View File
@@ -8,16 +8,3 @@ dist/
.turbo/
coverage/
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
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 apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \
@@ -21,7 +22,9 @@ RUN pnpm --filter @groombook/types build && \
# Runtime
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
ENV NODE_ENV=production
+1 -2
View File
@@ -24,14 +24,13 @@
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"uuid": "^11.1.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.18.0",
"tsx": "^4.19.2",
-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 insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let insertedAuditLogs: Record<string, unknown>[] = [];
function resetMock() {
selectRows = [];
selectSessionRow = null;
insertedValues = [];
updatedValues = [];
insertedAuditLogs = [];
}
vi.mock("@groombook/db", () => {
@@ -96,11 +94,6 @@ vi.mock("@groombook/db", () => {
{ 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 {
getDb: () => ({
select: () => ({
@@ -116,18 +109,9 @@ vi.mock("@groombook/db", () => {
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
// Only count waitlist entry inserts, not audit log inserts from portalAudit middleware
if (vals.petId || vals.serviceId || vals.status !== undefined) {
insertedValues.push(vals);
}
insertedValues.push(vals);
return {
returning: () => {
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" }];
},
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
};
},
}),
@@ -155,7 +139,6 @@ vi.mock("@groombook/db", () => {
}),
waitlistEntries,
impersonationSessions,
impersonationAuditLogs,
clients,
pets,
services,
+10 -25
View File
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.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 { setupRouter } from "./routes/setup.js";
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 { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js";
const app = new Hono();
@@ -70,9 +69,6 @@ app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Public Telnyx messaging webhook — signature-verified, no auth required
app.route("/api/webhooks/telnyx", telnyxWebhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
@@ -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
app.get("/api/branding", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
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
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
let logoUrl: string | null = 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
// 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/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer"));
api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager"));
// 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,
rateLimit: {
enabled: true,
max: 100,
window: 10,
max: 10,
window: 60,
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: [
genericOAuth({
@@ -246,15 +240,9 @@ export async function initAuth(): Promise<void> {
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
max: 10,
window: 60,
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: {
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,
sql,
} from "@groombook/db";
import type { AppEnv, StaffRole } from "../middleware/rbac.js";
import { requireRole } from "../middleware/rbac.js";
import type { AppEnv } from "../middleware/rbac.js";
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({
appointmentId: z.string().uuid().optional(),
clientId: z.string().uuid(),
@@ -102,8 +93,6 @@ invoicesRouter.get(
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
@@ -131,17 +120,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
let cardLast4: string | null = null;
let paymentStatus: string | null = null;
if (invoice.stripePaymentIntentId) {
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
if (details) {
cardLast4 = details.cardLast4;
paymentStatus = details.paymentStatus;
}
}
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
return c.json({ ...invoice, lineItems, tipSplits });
});
// Save tip splits for an invoice (replaces existing splits)
@@ -362,23 +341,30 @@ invoicesRouter.patch(
}
}
const tipCents = body.tipCents ?? current.tipCents;
// Validate tip splits when marking invoice as paid
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
if (body.tipSplits.length === 0) {
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
}
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
if (Math.abs(totalPct - 100) > 0.01) {
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
// Tip split validation when marking as paid with a tip
const effectiveTipCents = body.tipCents ?? current.tipCents;
if (body.status === "paid" && effectiveTipCents > 0) {
if (body.tipSplits !== undefined) {
if (body.tipSplits.length === 0) {
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
}
const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
if (totalBps !== 10000) {
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
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body;
const update: Record<string, unknown> = { ...bodyWithoutSplits, updatedAt: new Date() };
// Auto-set paidAt when marking as paid
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
@@ -392,50 +378,54 @@ invoicesRouter.patch(
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
}
// Wrap tip split persistence and invoice update in a single atomic transaction
const [updated, lineItems] = await db.transaction(async (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
const [updated] = await db.transaction(async (tx) => {
const [upd] = await tx
.update(invoices)
.set(update)
.where(eq(invoices.id, id))
.returning();
const lineItems = await tx
.select()
.from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, id));
// Atomically save tip splits when marking paid with provided splits
if (
body.status === "paid" &&
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 });
}
);
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
@@ -462,7 +452,7 @@ invoicesRouter.post(
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
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) => {
@@ -478,91 +468,15 @@ invoicesRouter.post(
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
const refundId = result.refundId;
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: refundId,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? 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
if (pet.photoKey) {
try {
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
}
await deleteObject(pet.photoKey);
}
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.photoKey) return c.json({ error: "No photo on file" }, 404);
try {
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
}
await deleteObject(pet.photoKey);
await db
.update(pets)
.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>();
// 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
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 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 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 staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({
id: a.id,
@@ -137,7 +78,7 @@ portalRouter.get("/appointments", async (c) => {
customerNotes: a.customerNotes,
notes: a.notes,
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,
}));
@@ -149,7 +90,7 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId");
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) => {
@@ -518,4 +459,73 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
const ok = await detachPaymentMethod(paymentMethodId);
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
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 { z } from "zod/v3";
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";
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
* Called after the client has successfully uploaded to the presigned URL.
@@ -215,8 +144,7 @@ settingsRouter.post(
/**
* GET /api/admin/settings/logo
* Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type.
* Returns a presigned GET URL for the logo.
*/
settingsRouter.get("/logo", async (c) => {
const db = getDb();
@@ -225,14 +153,8 @@ settingsRouter.get("/logo", async (c) => {
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",
},
});
const url = await getPresignedGetUrl(row.logoKey);
return c.json({ url, logoKey: row.logoKey });
});
/**
+1 -1
View File
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
const entry = rateLimitMap.get(ip);
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
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! };
}
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);
}
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(
to: string,
body: string,
@@ -103,7 +74,33 @@ export class TelnyxProvider implements SmsProvider {
}
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
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 },
});
}
if (url.includes("/api/invoices/stats/summary")) {
return route.fulfill({
json: {
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
},
});
}
if (url.includes("/api/invoices")) {
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 }) => {
// Mock portal billing endpoints
await page.route("**/api/portal/config**", (route) =>
route.fulfill({ json: { stripePublishableKey: "" } })
);
await page.route("**/api/portal/invoices**", (route) =>
route.fulfill({ json: [] })
);
await page.route("**/api/portal/payment-methods**", (route) =>
route.fulfill({ json: [] })
// Mock billing endpoint
await page.route("**/api/billing**", (route) =>
route.fulfill({ json: { invoices: [], balanceCents: 0 } })
);
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 { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
import { ServicesPage } from "./pages/Services.js";
import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
@@ -297,7 +296,6 @@ function AdminLayout() {
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/staff" element={<StaffPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
-10
View File
@@ -82,13 +82,3 @@ input:focus, select:focus, textarea:focus {
::-webkit-scrollbar-thumb:hover {
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");
// null key = unassigned; staffId string = that groomer; undefined set = all visible
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);
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
const loadAppointments = useCallback(() => {
const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString();
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
</button>
</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 ── */}
<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>
-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 type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
@@ -647,7 +647,8 @@ export function ClientsPage() {
{/* ── Client modal ── */}
{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}>
<Field label="Full name">
<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 ── */}
{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}>
<Field label="Pet name">
<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 ── */}
{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>}
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
<div style={{ marginBottom: "1rem" }}>
@@ -814,7 +817,8 @@ export function ClientsPage() {
{/* ── Delete confirmation modal ── */}
{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" }}>
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
</p>
@@ -852,60 +856,13 @@ export function ClientsPage() {
// ─── Shared UI ───────────────────────────────────────────────────────────────
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
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]);
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div
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(); }}
>
<div
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>
<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)" }}>
{children}
</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";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -173,21 +173,6 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
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}
const linkedAppt = invoice.appointmentId
@@ -350,19 +335,6 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/>
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{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>
{/* ── Tip Distribution ── */}
@@ -480,92 +452,11 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
{invoice.stripeRefundId && (
<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 style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
{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>
</Modal>
);
}
@@ -606,17 +497,9 @@ export function InvoicesPage() {
const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
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;
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) {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (statusFilter) params.set("status", statusFilter);
@@ -695,34 +578,6 @@ export function InvoicesPage() {
</button>
</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 ? (
<p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment.
@@ -827,63 +682,19 @@ export function InvoicesPage() {
// ─── Shared UI helpers ────────────────────────────────────────────────────────
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 (
<div
role="dialog"
aria-modal="true"
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(); }}
>
<div
ref={modalRef}
style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
<div style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}>
{children}
</div>
</div>
+49 -13
View File
@@ -89,14 +89,24 @@ export function SettingsPage() {
fetch("/api/admin/settings")
.then((r) => r.json())
.then(async (data) => {
// The logo is now proxied through the API server so the browser
// never receives an S3 URL — use the proxy path directly as the src.
let logoUrl: string | null = null;
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({
businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null,
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
logoUrl,
logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null,
});
@@ -148,21 +158,47 @@ export function SettingsPage() {
}
try {
// Upload directly through the API server to avoid mixed-content issues
// with pre-signed URLs that use the internal HTTP endpoint
const formData = new FormData();
formData.append("file", file);
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
// Step 1: Get presigned upload URL
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
method: "POST",
body: formData,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
});
if (!uploadRes.ok) {
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." });
refresh();
} catch (err: unknown) {
+2 -2
View File
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)}
{/* 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>
<h1 className="text-lg font-semibold text-stone-800">
@@ -340,7 +340,7 @@ export function CustomerPortal() {
</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()}
</div>
</main>
+22 -1
View File
@@ -123,7 +123,28 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) {
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 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 { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</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: "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 [isComplete, setIsComplete] = useState(false);
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) =>
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) {
return (
<div role="dialog" aria-modal="true" 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="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<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">
<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" />
@@ -482,8 +440,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
}
return (
<div role="dialog" aria-modal="true" 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="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
<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 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 || []);
// Filter for pending invoices only (not "outstanding")
+5 -4
View File
@@ -27,7 +27,8 @@ interface Appointment {
}
interface AppointmentsResponse {
appointments: Appointment[];
upcoming: Appointment[];
past: Appointment[];
}
interface Props {
@@ -45,7 +46,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -89,7 +90,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]);
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;
function handlePetSave(updatedPet: Pet) {
@@ -182,7 +183,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
)}
{/* 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: "medical", label: "Medical", icon: Heart },
+19 -2
View File
@@ -44,8 +44,25 @@ export function ReportCards({ sessionId }: Props) {
if (response.ok) {
const data = await response.json();
const allAppointments: Appointment[] = data.appointments || data || [];
const reportCardAppointments = allAppointments.filter(
const rawAppointments: Record<string, unknown>[] = data.appointments || data || [];
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
);
setAppointments(reportCardAppointments);
-7
View File
@@ -119,10 +119,3 @@ uri
database-url
{{- 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
value: {{ .Values.api.env.oidcAudience | quote }}
{{- 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
valueFrom:
secretKeyRef:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: ""
oidcIssuer: ""
oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000"
service:
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,
"tag": "0028_sms_reminders",
"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(),
});
export const appointments = pgTable(
"appointments",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "restrict" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "restrict" }),
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, {
onDelete: "set null",
}),
status: appointmentStatusEnum("status").notNull().default("scheduled"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
notes: text("notes"),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
// Recurring series support
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
groupId: uuid("group_id").references(() => appointmentGroups.id, {
onDelete: "set null",
}),
// Customer confirmation/cancellation tracking
// Values: "pending" | "confirmed" | "cancelled"
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
customerNotes: text("customer_notes"),
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 appointments = pgTable("appointments", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "restrict" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "restrict" }),
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, {
onDelete: "set null",
}),
status: appointmentStatusEnum("status").notNull().default("scheduled"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
notes: text("notes"),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
// Recurring series support
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
groupId: uuid("group_id").references(() => appointmentGroups.id, {
onDelete: "set null",
}),
// Customer confirmation/cancellation tracking
// Values: "pending" | "confirmed" | "cancelled"
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
customerNotes: text("customer_notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const invoices = pgTable(
"invoices",
@@ -406,117 +397,6 @@ export const impersonationAuditLogs = pgTable(
(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", {
id: uuid("id").primaryKey().defaultRandom(),
businessName: text("business_name").notNull().default("GroomBook"),
@@ -525,8 +405,6 @@ export const businessSettings = pgTable("business_settings", {
logoKey: text("logo_key"),
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
accentColor: text("accent_color").notNull().default("#8b7355"),
messagingPhoneNumber: text("messaging_phone_number"),
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
createdAt: timestamp("created_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 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({
id: invoiceId,
appointmentId: apptId,
@@ -990,7 +989,6 @@ async function seed() {
status: invoiceStatus,
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
paidAt,
stripePaymentIntentId,
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 totalCents = effectivePrice + taxCents + tipCents;
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({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt, stripePaymentIntentId, notes: null,
paidAt, notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,
-6
View File
@@ -152,16 +152,10 @@ export interface Invoice {
status: InvoiceStatus;
paymentMethod: PaymentMethod | null;
paidAt: string | null;
stripePaymentIntentId: string | null;
stripeRefundId: string | null;
paymentFailureReason: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
lineItems?: InvoiceLineItem[];
// Transient fields populated from Stripe API (not stored in DB)
cardLast4?: string | null;
paymentStatus?: string | null;
tipSplits?: InvoiceTipSplit[];
}
-19
View File
@@ -46,9 +46,6 @@ importers:
telnyx:
specifier: ^1.23.0
version: 1.27.0
uuid:
specifier: ^11.1.1
version: 11.1.1
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -62,9 +59,6 @@ importers:
'@types/nodemailer':
specifier: ^6.4.17
version: 6.4.23
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@vitest/coverage-v8':
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))
@@ -2340,9 +2334,6 @@ packages:
'@types/use-sync-external-store@0.0.6':
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':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4353,18 +4344,12 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
victory-vendor@37.3.6:
@@ -6925,8 +6910,6 @@ snapshots:
'@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)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -9031,8 +9014,6 @@ snapshots:
dependencies:
react: 19.2.4
uuid@11.1.1: {}
uuid@8.3.2: {}
uuid@9.0.1: {}