Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1745265fe3 |
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
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();
|
||||
};
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
export const appointmentGroupsRouter = new Hono();
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,8 +49,6 @@ 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)]
|
||||
@@ -91,16 +88,6 @@ 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);
|
||||
});
|
||||
|
||||
@@ -109,8 +96,6 @@ 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()
|
||||
@@ -126,7 +111,6 @@ 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,
|
||||
@@ -141,15 +125,6 @@ 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)
|
||||
@@ -165,13 +140,6 @@ 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);
|
||||
|
||||
@@ -276,28 +244,6 @@ 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)
|
||||
@@ -315,8 +261,6 @@ 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 })
|
||||
@@ -324,20 +268,6 @@ 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() })
|
||||
|
||||
@@ -41,10 +41,6 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -167,29 +163,6 @@ 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
|
||||
@@ -488,34 +461,6 @@ 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)
|
||||
|
||||
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
|
||||
|
||||
const bookingSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
startTime: z.string().datetime().refine(
|
||||
(dt) => new Date(dt) > new Date(),
|
||||
{ message: "Appointment must be in the future" }
|
||||
),
|
||||
startTime: z.string().datetime(),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientEmail: z.string().email(),
|
||||
clientPhone: z.string().max(50).optional(),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
export const groomingLogsRouter = new Hono();
|
||||
|
||||
const createLogSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
@@ -21,26 +20,6 @@ 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)
|
||||
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
|
||||
zValidator("json", createLogSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
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 { groomedAt, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.insert(groomingVisitLogs)
|
||||
.values({
|
||||
...rest,
|
||||
petId,
|
||||
appointmentId: appointmentId ?? null,
|
||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
|
||||
|
||||
groomingLogsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
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
|
||||
const [row] = await db
|
||||
.delete(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -44,61 +44,53 @@ const updateInvoiceSchema = z.object({
|
||||
});
|
||||
|
||||
// List invoices
|
||||
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("/", 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 });
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
+27
-130
@@ -1,33 +1,25 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, inArray } from "@groombook/db";
|
||||
import { 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";
|
||||
|
||||
export const portalRouter = new Hono<AppEnv>();
|
||||
type PortalEnv = AppEnv & PortalSessionEnv;
|
||||
|
||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||
export const portalRouter = new Hono<PortalEnv>();
|
||||
|
||||
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;
|
||||
}
|
||||
portalRouter.use(validatePortalSession);
|
||||
portalRouter.use(portalAuditMiddleware);
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
@@ -49,9 +41,7 @@ portalRouter.get("/services", async (c) => {
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const now = new Date();
|
||||
const allAppts = await db
|
||||
@@ -101,9 +91,7 @@ portalRouter.get("/appointments", async (c) => {
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
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, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||
@@ -111,9 +99,7 @@ portalRouter.get("/pets", async (c) => {
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
@@ -137,7 +123,6 @@ 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),
|
||||
});
|
||||
|
||||
@@ -148,12 +133,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -196,12 +176,7 @@ portalRouter.patch(
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -250,12 +225,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -276,7 +246,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Appointment is already cancelled or completed" }, 422);
|
||||
return c.json({ error: "Cannot cancel a cancelled or completed appointment" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
@@ -319,28 +289,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
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 clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
@@ -364,26 +313,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
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 clientId = c.get("portalClientId");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -392,7 +322,7 @@ portalRouter.patch(
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== session.clientId) {
|
||||
if (existing.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -414,26 +344,7 @@ portalRouter.patch(
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
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 clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
@@ -442,7 +353,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== session.clientId) {
|
||||
if (entry.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -475,9 +386,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
@@ -514,9 +423,7 @@ portalRouter.post(
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
@@ -524,9 +431,7 @@ portalRouter.get("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
@@ -539,9 +444,7 @@ portalRouter.post("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
@@ -580,7 +483,6 @@ portalRouter.post(
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
@@ -590,10 +492,6 @@ 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;
|
||||
@@ -604,7 +502,6 @@ portalRouter.post(
|
||||
.limit(1);
|
||||
|
||||
if (!demoStaff) {
|
||||
// Fall back to any active staff member
|
||||
const [firstStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
@@ -622,10 +519,10 @@ portalRouter.post(
|
||||
staffId,
|
||||
clientId: body.clientId,
|
||||
reason: "dev-mode-client-portal",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(session, 201);
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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().max(480),
|
||||
durationMinutes: z.number().int().positive(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -45,13 +44,10 @@ 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, invoiceIdTrimmed))
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.limit(1);
|
||||
if (!inv) continue;
|
||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||
@@ -64,7 +60,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
stripePaymentIntentId: pi.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
@@ -73,16 +69,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();
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.refunded") {
|
||||
|
||||
Reference in New Issue
Block a user