Compare commits

..

7 Commits

Author SHA1 Message Date
Flea Flicker c89c2fd6b4 Revert RBAC/authorization changes in appointmentGroups and groomingLogs
These files are out of scope for the input validation PR. Only the
5-route validation changes (invoices, book, appointments, services,
stripe-webhooks) should be included.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 02:09:29 +00:00
Paperclip 203b600713 fix(stripe-webhooks): validate invoice IDs as UUIDs before DB lookup 2026-04-14 14:00:02 +00:00
Paperclip b230e015c2 fix(services): cap durationMinutes at 480 (8 hours max) 2026-04-14 13:59:59 +00:00
Paperclip 53b2dc6067 fix(appointments): cap recurrence series at 1 year max 2026-04-14 13:59:54 +00:00
Paperclip 1bdfa9f3d2 fix(book): add future-time refinement to booking startTime 2026-04-14 13:59:51 +00:00
Paperclip 369c2ce182 fix(invoices): add Zod query param validation to GET / 2026-04-14 13:59:50 +00:00
Paperclip 5e24678fa5 feat(GRO-635): implement groomer data isolation in appointmentGroups, groomingLogs + fix batherStaffId conflict check
- appointmentGroups: use Hono<AppEnv>(), add groomer isolation on all endpoints
- groomingLogs: use Hono<AppEnv>(), add groomer isolation on all endpoints
- appointments: add batherStaffId conflict check in POST and PATCH handlers
- Non-groomer roles retain full access on all endpoints

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 13:50:03 +00:00
11 changed files with 417 additions and 309 deletions
@@ -1,158 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAuditMiddleware } from "../middleware/portalAudit.js";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const pastDate = () => new Date(Date.now() - 5 * 60 * 1000);
const ACTIVE_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: futureDate(),
createdAt: new Date(),
};
const EXPIRED_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: pastDate(),
createdAt: new Date(),
};
let selectSessionRow: Record<string, unknown> | null = null;
let insertedAuditLogs: Array<Record<string, unknown>> = [];
function resetMock() {
selectSessionRow = null;
insertedAuditLogs = [];
}
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const impersonationSessions = new Proxy(
{ _name: "impersonationSessions" },
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
);
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
}
return makeChainable([]);
},
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
insertedAuditLogs.push(vals);
return {
returning: () => [{ id: "audit-log-uuid-1", ...vals }],
};
},
}),
}),
impersonationSessions,
impersonationAuditLogs,
eq: vi.fn(),
and: vi.fn(),
};
});
const app = new Hono();
app.use(validatePortalSession);
app.use(portalAuditMiddleware);
app.get("/test", (c) => c.json({ ok: true }));
function makeRequest(path: string, headers?: Record<string, string>) {
return app.request(path, { headers });
}
beforeEach(() => resetMock());
// ─── validatePortalSession tests ──────────────────────────────────────────────
describe("validatePortalSession", () => {
it("calls next and sets context variables for valid active session", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
});
it("returns 401 when X-Impersonation-Session-Id header is missing", async () => {
const res = await makeRequest("/test");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 401 when session is expired", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 401 when session is not found", async () => {
selectSessionRow = null;
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
});
// ─── portalAuditMiddleware tests ──────────────────────────────────────────────
describe("portalAuditMiddleware", () => {
it("inserts audit log entry after successful request", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
expect(insertedAuditLogs).toHaveLength(1);
expect(insertedAuditLogs[0].sessionId).toBe(SESSION_ID);
expect(insertedAuditLogs[0].action).toBe("GET /test");
expect(insertedAuditLogs[0].pageVisited).toBe("/test");
expect(insertedAuditLogs[0].metadata).toEqual({ method: "GET", statusCode: 200 });
});
it("does not throw when audit log insert fails", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
});
it("does not insert audit log when portalSessionId is not set", async () => {
const res = await makeRequest("/test");
expect(res.status).toBe(401);
expect(insertedAuditLogs).toHaveLength(0);
});
});
-28
View File
@@ -1,28 +0,0 @@
import type { MiddlewareHandler } from "hono";
import { getDb, impersonationAuditLogs } from "@groombook/db";
import type { PortalSessionEnv } from "./portalSession.js";
export const portalAuditMiddleware: MiddlewareHandler<PortalSessionEnv> = async (
c,
next
) => {
await next();
const sessionId = c.get("portalSessionId");
if (!sessionId) return;
const action = `${c.req.method} ${c.req.path}`;
const metadata = { method: c.req.method, statusCode: c.res.status };
try {
const db = getDb();
await db.insert(impersonationAuditLogs).values({
sessionId,
action,
pageVisited: c.req.path,
metadata,
});
} catch (err) {
console.error("[portalAudit] failed to insert audit log:", err);
}
};
-39
View File
@@ -1,39 +0,0 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
export interface PortalSessionEnv {
Variables: {
portalClientId: string;
portalSessionId: string;
};
}
export const validatePortalSession: MiddlewareHandler<PortalSessionEnv> = async (
c,
next
) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const db = getDb();
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
c.set("portalClientId", session.clientId);
c.set("portalSessionId", session.id);
await next();
};
+71 -1
View File
@@ -16,8 +16,9 @@ import {
services,
staff,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono();
export const appointmentGroupsRouter = new Hono<AppEnv>();
// ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId");
const from = c.req.query("from");
const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)]
@@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => {
}))
.filter((g) => !from || g.appointments.length > 0);
if (isGroomer) {
return c.json(
result.filter((g) =>
g.appointments.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
)
);
}
return c.json(result);
});
@@ -96,6 +109,8 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select()
@@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId,
serviceName: services.name,
staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name,
status: appointments.status,
startTime: appointments.startTime,
@@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id))
.orderBy(appointments.startTime);
if (
isGroomer &&
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
const [client] = await db
.select({ name: clients.name, email: clients.email })
.from(clients)
@@ -140,6 +165,13 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema),
async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (staffRow?.role === "groomer") {
return c.json(
{ error: "Forbidden: groomers cannot create group bookings" },
403
);
}
const body = c.req.valid("json");
const startTime = new Date(body.startTime);
@@ -244,6 +276,28 @@ appointmentGroupsRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
const [updated] = await db
.update(appointmentGroups)
@@ -261,6 +315,8 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
@@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
await db
.update(appointments)
.set({ status: "cancelled", updatedAt: new Date() })
+55
View File
@@ -41,6 +41,10 @@ const createAppointmentSchema = z.object({
frequencyWeeks: z.number().int().min(1).max(52),
count: z.number().int().min(2).max(52),
})
.refine(
(r) => r.frequencyWeeks * r.count <= 52,
{ message: "Recurrence series must not exceed 1 year" }
)
.optional(),
});
@@ -163,6 +167,29 @@ appointmentsRouter.post(
}
}
// Check batherStaffId conflicts if set
if (apptFields.batherStaffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
if (!recurrence) {
// Single appointment
const [inserted] = await tx
@@ -461,6 +488,34 @@ appointmentsRouter.patch(
}
}
// Check batherStaffId conflicts if being updated or already set
const batherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: current.batherStaffId;
if (batherStaffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, batherStaffId),
eq(appointments.batherStaffId, batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
const [updated] = await tx
.update(appointments)
.set(update)
+4 -1
View File
@@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime(),
startTime: z.string().datetime().refine(
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
+93 -6
View File
@@ -1,9 +1,10 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono();
export const groomingLogsRouter = new Hono<AppEnv>();
const createLogSchema = z.object({
petId: z.string().uuid(),
@@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb();
const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400);
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
const rows = await db
.select()
.from(groomingVisitLogs)
@@ -33,11 +54,50 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema),
async (c) => {
const db = getDb();
const { groomedAt, ...rest } = c.req.valid("json");
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
if (appointmentId) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.id, appointmentId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
} else {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
}
const [row] = await db
.insert(groomingVisitLogs)
.values({
...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
})
.returning();
@@ -47,10 +107,37 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [log] = await db
.select()
.from(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.limit(1);
if (!log) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, log.petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
await db
.delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, c.req.param("id")))
.where(eq(groomingVisitLogs.id, id))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+53 -45
View File
@@ -44,53 +44,61 @@ const updateInvoiceSchema = z.object({
});
// List invoices
invoicesRouter.get("/", async (c) => {
const db = getDb();
const clientId = c.req.query("clientId");
const appointmentId = c.req.query("appointmentId");
const status = c.req.query("status");
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
const offset = parseInt(c.req.query("offset") || "0", 10);
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
const listInvoicesQuerySchema = z.object({
clientId: z.string().uuid().optional(),
appointmentId: z.string().uuid().optional(),
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
invoicesRouter.get(
"/",
zValidator("query", listInvoicesQuerySchema),
async (c) => {
const db = getDb();
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
}
);
// Get single invoice with line items and tip splits
invoicesRouter.get("/:id", async (c) => {
const db = getDb();
+130 -27
View File
@@ -1,25 +1,33 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, inArray } from "@groombook/db";
import { and, eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import type { PortalSessionEnv } from "../middleware/portalSession.js";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAuditMiddleware } from "../middleware/portalAudit.js";
type PortalEnv = AppEnv & PortalSessionEnv;
export const portalRouter = new Hono<AppEnv>();
export const portalRouter = new Hono<PortalEnv>();
// ─── Session helper ───────────────────────────────────────────────────────────
portalRouter.use(validatePortalSession);
portalRouter.use(portalAuditMiddleware);
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
if (!sessionId) return null;
const db = getDb();
const [session] = await db
.select()
.from(impersonationSessions)
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
.limit(1);
if (!session || session.expiresAt <= new Date()) return null;
return session.clientId;
}
// ─── GET routes ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return c.json({ error: "Not found" }, 404);
@@ -41,7 +49,9 @@ portalRouter.get("/services", async (c) => {
portalRouter.get("/appointments", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const now = new Date();
const allAppts = await db
@@ -91,7 +101,9 @@ portalRouter.get("/appointments", async (c) => {
portalRouter.get("/pets", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
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, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
@@ -99,7 +111,9 @@ portalRouter.get("/pets", async (c) => {
portalRouter.get("/invoices", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
const invoiceIds = clientInvoices.map(i => i.id);
@@ -123,6 +137,7 @@ portalRouter.get("/invoices", async (c) => {
// ─── Appointment action routes ────────────────────────────────────────────────
const customerNotesSchema = z.object({
// .min(1) prevents empty strings — clearing notes is not a supported use case
customerNotes: z.string().min(1).max(500),
});
@@ -133,7 +148,12 @@ portalRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
@@ -176,7 +196,12 @@ portalRouter.patch(
portalRouter.post("/appointments/:id/confirm", async (c) => {
const db = getDb();
const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
@@ -225,7 +250,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
portalRouter.post("/appointments/:id/cancel", async (c) => {
const db = getDb();
const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
@@ -246,7 +276,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
}
if (appt.status === "cancelled" || appt.status === "completed") {
return c.json({ error: "Cannot cancel a cancelled or completed appointment" }, 422);
return c.json({ error: "Appointment is already cancelled or completed" }, 422);
}
const [updated] = await db
@@ -289,7 +319,28 @@ portalRouter.post(
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
let clientId: string | null = null;
if (sessionId) {
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (session && session.expiresAt > new Date()) {
clientId = session.clientId;
}
}
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [entry] = await db
.insert(waitlistEntries)
@@ -313,7 +364,26 @@ portalRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const [existing] = await db
.select()
@@ -322,7 +392,7 @@ portalRouter.patch(
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) {
if (existing.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -344,7 +414,26 @@ portalRouter.patch(
portalRouter.delete("/waitlist/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const [entry] = await db
.select()
@@ -353,7 +442,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.limit(1);
if (!entry) return c.json({ error: "Not found" }, 404);
if (entry.clientId !== clientId) {
if (entry.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -386,7 +475,9 @@ portalRouter.post(
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db
.select()
@@ -423,7 +514,9 @@ portalRouter.post(
);
portalRouter.get("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
@@ -431,7 +524,9 @@ portalRouter.get("/payment-methods", async (c) => {
});
portalRouter.post("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId);
@@ -444,7 +539,9 @@ portalRouter.post("/payment-methods", async (c) => {
});
portalRouter.delete("/payment-methods/:id", async (c) => {
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id");
@@ -483,6 +580,7 @@ portalRouter.post(
const db = getDb();
const body = c.req.valid("json");
// Verify client exists
const [client] = await db
.select()
.from(clients)
@@ -492,6 +590,10 @@ portalRouter.post(
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;
@@ -502,6 +604,7 @@ portalRouter.post(
.limit(1);
if (!demoStaff) {
// Fall back to any active staff member
const [firstStaff] = await db
.select({ id: staff.id })
.from(staff)
@@ -519,10 +622,10 @@ portalRouter.post(
staffId,
clientId: body.clientId,
reason: "dev-mode-client-portal",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
})
.returning();
return c.json(session, 201);
}
);
);
+1 -1
View File
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480),
active: z.boolean().default(true),
});
+10 -3
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import Stripe from "stripe";
import { z } from "zod/v3";
import { eq, getDb, invoices } from "@groombook/db";
import { getStripeClient } from "../services/payment.js";
@@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
const [inv] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.where(eq(invoices.id, invoiceIdTrimmed))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
@@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => {
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "payment_intent.payment_failed") {
@@ -69,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
await db
.update(invoices)
.set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "charge.refunded") {