Extract groombook/api from monorepo with CI workflow
- Add source code from apps/api - Add packages/db and packages/types workspace dependencies - Add GitHub Actions CI workflow (lint, typecheck, test, docker) - Generate pnpm-lock.yaml - Add .gitignore Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Admin seed endpoint — populates minimal known-user seed data via the API.
|
||||
*
|
||||
* This is the canonical way to seed prod/demo data. The old approach (seed.ts
|
||||
* writing directly to the DB) bypasses API validation and audit trails.
|
||||
*
|
||||
* Security: This endpoint is manager-only (enforced via requireRole in index.ts).
|
||||
* It is disabled when AUTH_DISABLED=true — dev/test seeding should use the
|
||||
* direct-DB seed.ts in that mode.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { eq, getDb, staff, clients, pets, services } from "@groombook/db";
|
||||
|
||||
export const adminSeedRouter = new Hono();
|
||||
|
||||
const KNOWN_STAFF = {
|
||||
name: "Demo Manager",
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager" as const,
|
||||
active: true,
|
||||
};
|
||||
|
||||
const KNOWN_CLIENT = {
|
||||
name: "Demo Client",
|
||||
email: "demo-client@example.com",
|
||||
phone: "555-0001",
|
||||
address: "1 Demo Street, Demo City, CA 90210",
|
||||
};
|
||||
|
||||
const DEMO_PET = {
|
||||
name: "Demo Dog",
|
||||
species: "Dog",
|
||||
breed: "Golden Retriever",
|
||||
weightKg: "30.00",
|
||||
};
|
||||
|
||||
const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||
];
|
||||
|
||||
adminSeedRouter.post("/seed", async (c) => {
|
||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Seed endpoint is not available when AUTH_DISABLED=true. Use direct DB seeding for dev/test environments.",
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const results: string[] = [];
|
||||
|
||||
// ── Staff: Demo Manager ─────────────────────────────────────────────────────
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.email, KNOWN_STAFF.email));
|
||||
|
||||
if (existingStaff) {
|
||||
results.push(`Staff '${KNOWN_STAFF.name}' already exists (id: ${existingStaff.id})`);
|
||||
} else {
|
||||
const [created] = await db.insert(staff).values(KNOWN_STAFF).returning();
|
||||
results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`);
|
||||
}
|
||||
|
||||
// ── Services: idempotent upsert using name as unique key ────────────────────
|
||||
// NOTE: UNIQUE constraint on services.name must exist (via migration 0020).
|
||||
// Both this admin seed and the main DB seed use the same deterministic IDs
|
||||
// and ON CONFLICT (name), ensuring consistency across both seed paths.
|
||||
for (const svc of DEMO_SERVICES) {
|
||||
await db.insert(services)
|
||||
.values({ ...svc, active: true })
|
||||
.onConflictDoUpdate({
|
||||
target: services.name,
|
||||
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
||||
});
|
||||
}
|
||||
results.push(`Upserted ${DEMO_SERVICES.length} services`);
|
||||
|
||||
// ── Client: Demo Client ───────────────────────────────────────────────────
|
||||
const [existingClient] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, KNOWN_CLIENT.email));
|
||||
|
||||
let clientId: string;
|
||||
if (existingClient) {
|
||||
clientId = existingClient.id;
|
||||
results.push(`Client '${KNOWN_CLIENT.name}' already exists (id: ${clientId})`);
|
||||
} else {
|
||||
const [created] = await db.insert(clients).values(KNOWN_CLIENT).returning();
|
||||
clientId = created!.id;
|
||||
results.push(`Created client '${KNOWN_CLIENT.name}' (id: ${clientId})`);
|
||||
}
|
||||
|
||||
// ── Pet: Demo Dog ──────────────────────────────────────────────────────────
|
||||
const existingPets = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, clientId));
|
||||
|
||||
const demoDog = existingPets.find(
|
||||
(p) => p.name === DEMO_PET.name && p.species === DEMO_PET.species
|
||||
);
|
||||
|
||||
if (demoDog) {
|
||||
results.push(`Pet '${DEMO_PET.name}' already exists for Demo Client (id: ${demoDog.id})`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId,
|
||||
name: DEMO_PET.name,
|
||||
species: DEMO_PET.species,
|
||||
breed: DEMO_PET.breed,
|
||||
weightKg: DEMO_PET.weightKg,
|
||||
dateOfBirth: new Date("2020-06-15T00:00:00Z"),
|
||||
})
|
||||
.returning();
|
||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Seed complete",
|
||||
details: results,
|
||||
credentials: {
|
||||
note: "For dev-mode access, use X-Dev-User-Id: demo-manager-001 header",
|
||||
staffOidcSub: KNOWN_STAFF.oidcSub,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
appointmentGroups,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const petAppointmentSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
// Each pet may have a different end time (e.g. small dog done faster)
|
||||
endTime: z.string().datetime(),
|
||||
priceCents: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const createGroupSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
startTime: z.string().datetime(),
|
||||
// One entry per pet
|
||||
pets: z.array(petAppointmentSchema).min(2, "A group booking requires at least 2 pets"),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const updateGroupSchema = z.object({
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
// ─── List groups (compact, with appointment count and start time) ─────────────
|
||||
|
||||
appointmentGroupsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
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)]
|
||||
: [];
|
||||
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(appointmentGroups)
|
||||
.where(groupConditions.length > 0 ? and(...groupConditions) : undefined)
|
||||
.orderBy(appointmentGroups.createdAt);
|
||||
|
||||
if (groups.length === 0) return c.json([]);
|
||||
|
||||
// Fetch appointments for all groups (filter by time range if provided)
|
||||
const apptConditions = [];
|
||||
if (from) apptConditions.push(gte(appointments.startTime, new Date(from)));
|
||||
if (to) apptConditions.push(lte(appointments.startTime, new Date(to)));
|
||||
|
||||
const allAppts = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(apptConditions.length > 0 ? and(...apptConditions) : undefined);
|
||||
|
||||
const groupApptMap = new Map<string, typeof appointments.$inferSelect[]>();
|
||||
for (const appt of allAppts) {
|
||||
if (!appt.groupId) continue;
|
||||
if (!groupApptMap.has(appt.groupId)) groupApptMap.set(appt.groupId, []);
|
||||
groupApptMap.get(appt.groupId)!.push(appt);
|
||||
}
|
||||
|
||||
const result = groups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
appointments: (groupApptMap.get(g.id) ?? []).sort(
|
||||
(a, b) => a.startTime.getTime() - b.startTime.getTime()
|
||||
),
|
||||
}))
|
||||
.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);
|
||||
});
|
||||
|
||||
// ─── Get single group with its appointments ───────────────────────────────────
|
||||
|
||||
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()
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const groupAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
petId: appointments.petId,
|
||||
petName: pets.name,
|
||||
serviceId: appointments.serviceId,
|
||||
serviceName: services.name,
|
||||
staffId: appointments.staffId,
|
||||
batherStaffId: appointments.batherStaffId,
|
||||
staffName: staff.name,
|
||||
status: appointments.status,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
priceCents: appointments.priceCents,
|
||||
notes: appointments.notes,
|
||||
})
|
||||
.from(appointments)
|
||||
.leftJoin(pets, eq(appointments.petId, pets.id))
|
||||
.leftJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.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)
|
||||
.where(eq(clients.id, group.clientId));
|
||||
|
||||
return c.json({ ...group, client, appointments: groupAppts });
|
||||
});
|
||||
|
||||
// ─── Create group booking ─────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId));
|
||||
if (!client) return c.json({ error: "Client not found" }, 404);
|
||||
|
||||
// Verify all pets belong to this client
|
||||
const petIds = body.pets.map((p) => p.petId);
|
||||
const petRows = await db
|
||||
.select({ id: pets.id, clientId: pets.clientId })
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, body.clientId));
|
||||
const ownedPetIds = new Set(petRows.map((p) => p.id));
|
||||
const unauthorized = petIds.filter((id) => !ownedPetIds.has(id));
|
||||
if (unauthorized.length > 0) {
|
||||
return c.json({ error: `Pet(s) not found for this client: ${unauthorized.join(", ")}` }, 422);
|
||||
}
|
||||
|
||||
// Deduplicate pets in a single booking
|
||||
if (new Set(petIds).size !== petIds.length) {
|
||||
return c.json({ error: "Each pet can only appear once per group booking" }, 422);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Check conflicts for each staff member
|
||||
for (const pet of body.pets) {
|
||||
if (!pet.staffId) continue;
|
||||
const endTime = new Date(pet.endTime);
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, pet.staffId),
|
||||
lt(appointments.startTime, endTime),
|
||||
gte(appointments.endTime, startTime),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(`Staff conflict for pet ${pet.petId}`),
|
||||
{ statusCode: 409, petId: pet.petId, staffId: pet.staffId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the group record
|
||||
const [group] = await tx
|
||||
.insert(appointmentGroups)
|
||||
.values({ clientId: body.clientId, notes: body.notes ?? null })
|
||||
.returning();
|
||||
if (!group) throw new Error("Failed to create appointment group");
|
||||
|
||||
// Create one appointment per pet
|
||||
const createdAppts = [];
|
||||
for (const pet of body.pets) {
|
||||
const endTime = new Date(pet.endTime);
|
||||
const [appt] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
clientId: body.clientId,
|
||||
petId: pet.petId,
|
||||
serviceId: pet.serviceId,
|
||||
staffId: pet.staffId ?? null,
|
||||
startTime,
|
||||
endTime,
|
||||
priceCents: pet.priceCents ?? null,
|
||||
groupId: group.id,
|
||||
})
|
||||
.returning();
|
||||
if (appt) createdAppts.push(appt);
|
||||
}
|
||||
|
||||
return { group, appointments: createdAppts };
|
||||
});
|
||||
|
||||
return c.json(result, 201);
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { statusCode?: number };
|
||||
if (e.statusCode === 409) {
|
||||
return c.json({ error: "A staff member has a conflicting appointment at this time", detail: e.message }, 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Update group notes ───────────────────────────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateGroupSchema),
|
||||
async (c) => {
|
||||
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)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(appointmentGroups.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Cancel all appointments in a group ──────────────────────────────────────
|
||||
|
||||
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 })
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.groupId, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,845 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
or,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
recurringSeries,
|
||||
reminderLogs,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
delayMs: number,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await fn();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`[appointments] ${context}: ${lastError}`);
|
||||
}
|
||||
|
||||
export const appointmentsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createAppointmentSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
batherStaffId: z.string().uuid().optional(),
|
||||
startTime: z.string().datetime(),
|
||||
endTime: z.string().datetime(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
priceCents: z.number().int().positive().optional(),
|
||||
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
|
||||
recurrence: 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(),
|
||||
});
|
||||
|
||||
const updateAppointmentSchema = z.object({
|
||||
staffId: z.string().uuid().nullable().optional(),
|
||||
batherStaffId: z.string().uuid().nullable().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
"scheduled",
|
||||
"confirmed",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"no_show",
|
||||
])
|
||||
.optional(),
|
||||
startTime: z.string().datetime().optional(),
|
||||
endTime: z.string().datetime().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
priceCents: z.number().int().positive().nullable().optional(),
|
||||
// When updating a series member, optionally propagate the change
|
||||
cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(),
|
||||
});
|
||||
|
||||
// List appointments, optionally filtered by date range or staffId.
|
||||
// Groomers see only their own appointments (staffId or batherStaffId).
|
||||
appointmentsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffId = c.req.query("staffId");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const conditions = [];
|
||||
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
|
||||
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
||||
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
||||
|
||||
// Groomer: restrict to their own appointments (as groomer or bather)
|
||||
if (isGroomer) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(and(...conditions))
|
||||
.orderBy(appointments.startTime)
|
||||
: await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
appointmentsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if not assigned as groomer or bather
|
||||
if (isGroomer && row.staffId !== staffRow.id && row.batherStaffId !== staffRow.id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
appointmentsRouter.post(
|
||||
"/",
|
||||
zValidator("json", createAppointmentSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const start = new Date(body.startTime);
|
||||
const end = new Date(body.endTime);
|
||||
|
||||
if (end <= start) {
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
}
|
||||
|
||||
const { recurrence, ...apptFields } = body;
|
||||
|
||||
// Wrap conflict check + insert in a transaction to prevent double-booking
|
||||
// race conditions under concurrent load (fixes #18).
|
||||
let firstRow: typeof appointments.$inferSelect;
|
||||
try {
|
||||
firstRow = await db.transaction(async (tx) => {
|
||||
// Conflict check applies to the first occurrence only; subsequent
|
||||
// occurrences are spread weeks apart so conflicts are unlikely and can
|
||||
// be resolved individually if needed.
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
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 (apptFields.batherStaffId) {
|
||||
const bathConflicts = 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 (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!recurrence) {
|
||||
// Single appointment
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({ ...apptFields, startTime: start, endTime: end })
|
||||
.returning();
|
||||
if (!inserted) throw new Error("Insert failed");
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// Create recurring series
|
||||
const seriesRows = await tx
|
||||
.insert(recurringSeries)
|
||||
.values({ frequencyWeeks: recurrence.frequencyWeeks })
|
||||
.returning();
|
||||
const series = seriesRows[0];
|
||||
if (!series) throw new Error("Failed to create recurring series");
|
||||
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const intervalMs =
|
||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let first: typeof appointments.$inferSelect | undefined;
|
||||
const conflictingInstances: number[] = [];
|
||||
for (let i = 0; i < recurrence.count; i++) {
|
||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||
const instanceEnd = new Date(
|
||||
instanceStart.getTime() + durationMs
|
||||
);
|
||||
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
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, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
...apptFields,
|
||||
startTime: instanceStart,
|
||||
endTime: instanceEnd,
|
||||
seriesId: series.id,
|
||||
seriesIndex: i,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||
if (i === 0) first = inserted;
|
||||
}
|
||||
|
||||
if (conflictingInstances.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
||||
),
|
||||
{ statusCode: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!first) throw new Error("No appointments created");
|
||||
return first;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err as Error & { statusCode?: number }).statusCode === 409
|
||||
) {
|
||||
return c.json(
|
||||
{ error: "Staff member has a conflicting appointment at this time" },
|
||||
409
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send confirmation email (fire-and-forget — never fails the request)
|
||||
withRetry(
|
||||
() => sendConfirmationEmail(db, firstRow),
|
||||
2,
|
||||
1000,
|
||||
`Failed to send confirmation email for appointment ${firstRow.id}`
|
||||
);
|
||||
|
||||
return c.json(firstRow, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Confirmation email helper ─────────────────────────────────────────────
|
||||
|
||||
async function sendConfirmationEmail(
|
||||
db: ReturnType<typeof getDb>,
|
||||
appt: typeof appointments.$inferSelect
|
||||
): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
groomerName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(eq(appointments.id, appt.id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return;
|
||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) return;
|
||||
if (!petName || !serviceName) return;
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildConfirmationEmail(clientEmail, {
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: groomerName ?? null,
|
||||
startTime: appt.startTime,
|
||||
})
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: "confirmation" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
appointmentsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateAppointmentSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const { cascadeMode = "this_only", ...updateFields } = body;
|
||||
|
||||
// ── Cascade update (this_and_future / all) ────────────────────────────────
|
||||
if (cascadeMode !== "this_only") {
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) {
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
// Compute time deltas and apply them uniformly across the series so
|
||||
// all instances shift by the same amount (e.g. rescheduled 1 hr later).
|
||||
const startDeltaMs = updateFields.startTime
|
||||
? new Date(updateFields.startTime).getTime() -
|
||||
current.startTime.getTime()
|
||||
: 0;
|
||||
const endDeltaMs = updateFields.endTime
|
||||
? new Date(updateFields.endTime).getTime() -
|
||||
current.endTime.getTime()
|
||||
: 0;
|
||||
|
||||
// Validate resulting times on the anchor appointment
|
||||
const newStart = new Date(
|
||||
current.startTime.getTime() + startDeltaMs
|
||||
);
|
||||
const newEnd = new Date(current.endTime.getTime() + endDeltaMs);
|
||||
if (newEnd <= newStart) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which appointments to update
|
||||
let whereClause;
|
||||
if (current.seriesId && current.seriesIndex !== null) {
|
||||
whereClause =
|
||||
cascadeMode === "this_and_future"
|
||||
? and(
|
||||
eq(appointments.seriesId, current.seriesId),
|
||||
gte(appointments.seriesIndex, current.seriesIndex),
|
||||
)
|
||||
: eq(appointments.seriesId, current.seriesId);
|
||||
} else {
|
||||
// Not part of a series — fall back to single update
|
||||
whereClause = eq(appointments.id, id);
|
||||
}
|
||||
|
||||
const affected = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(whereClause);
|
||||
|
||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||
for (const appt of affected) {
|
||||
const newStart =
|
||||
startDeltaMs !== 0
|
||||
? new Date(appt.startTime.getTime() + startDeltaMs)
|
||||
: appt.startTime;
|
||||
const newEnd =
|
||||
endDeltaMs !== 0
|
||||
? new Date(appt.endTime.getTime() + endDeltaMs)
|
||||
: appt.endTime;
|
||||
const newStaffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: appt.staffId;
|
||||
const newBatherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: appt.batherStaffId;
|
||||
|
||||
if (
|
||||
newStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.staffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, newStaffId),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newBatherStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.batherStaffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, newBatherStaffId),
|
||||
eq(appointments.batherStaffId, newBatherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const apptUpdate: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (updateFields.staffId !== undefined)
|
||||
apptUpdate.staffId = updateFields.staffId;
|
||||
if (updateFields.notes !== undefined)
|
||||
apptUpdate.notes = updateFields.notes;
|
||||
if (updateFields.status !== undefined)
|
||||
apptUpdate.status = updateFields.status;
|
||||
if (updateFields.priceCents !== undefined)
|
||||
apptUpdate.priceCents = updateFields.priceCents;
|
||||
if (startDeltaMs !== 0)
|
||||
apptUpdate.startTime = new Date(
|
||||
appt.startTime.getTime() + startDeltaMs
|
||||
);
|
||||
if (endDeltaMs !== 0)
|
||||
apptUpdate.endTime = new Date(
|
||||
appt.endTime.getTime() + endDeltaMs
|
||||
);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(apptUpdate)
|
||||
.where(eq(appointments.id, appt.id))
|
||||
.returning();
|
||||
if (appt.id === id) firstUpdated = updated;
|
||||
}
|
||||
|
||||
return firstUpdated;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
// ── this_only (original logic) ────────────────────────────────────────────
|
||||
const needsConflictCheck =
|
||||
updateFields.startTime !== undefined ||
|
||||
updateFields.endTime !== undefined ||
|
||||
updateFields.staffId !== undefined ||
|
||||
updateFields.batherStaffId !== undefined;
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
...updateFields,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (updateFields.startTime) update.startTime = new Date(updateFields.startTime);
|
||||
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
|
||||
|
||||
if (needsConflictCheck) {
|
||||
// Wrap conflict check + update in a transaction to prevent race conditions
|
||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) {
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
const start = updateFields.startTime
|
||||
? new Date(updateFields.startTime)
|
||||
: current.startTime;
|
||||
const end = updateFields.endTime
|
||||
? new Date(updateFields.endTime)
|
||||
: current.endTime;
|
||||
// Use provided staffId (may be null to unassign); fall back to existing
|
||||
const staffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: current.staffId;
|
||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
|
||||
if (end <= start) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
if (staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, staffId),
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
if (batherStaffId) {
|
||||
const bathConflicts = 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 (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// Soft-delete: cancel the appointment instead of removing the row,
|
||||
// preserving audit trail and financial records (fixes #20).
|
||||
// Optional ?cascade=this_only|this_and_future|all for series appointments.
|
||||
appointmentsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const cascade = c.req.query("cascade") ?? "this_only";
|
||||
|
||||
if (cascade === "this_and_future" || cascade === "all") {
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (current.seriesId && current.seriesIndex !== null) {
|
||||
const whereClause =
|
||||
cascade === "this_and_future"
|
||||
? and(
|
||||
eq(appointments.seriesId, current.seriesId),
|
||||
gte(appointments.seriesIndex, current.seriesIndex),
|
||||
)
|
||||
: eq(appointments.seriesId, current.seriesId);
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(whereClause);
|
||||
} else {
|
||||
// Not in a series — cancel only this one
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id));
|
||||
}
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
// Single cancel (default)
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
|
||||
const [row] = await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/confirm ───────────────────────────────────────
|
||||
// Staff/portal: confirm a specific appointment by ID. Idempotent.
|
||||
|
||||
appointmentsRouter.post("/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Cannot confirm a cancelled appointment" }, 409);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.json(appt); // idempotent
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/cancel ───────────────────────────────────────
|
||||
// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified.
|
||||
|
||||
appointmentsRouter.post("/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Appointment is already cancelled" }, 409);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── Token generation helper ──────────────────────────────────────────────────
|
||||
|
||||
export function generateConfirmationToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
import { reinitAuth } from "../lib/auth.js";
|
||||
|
||||
export const authProviderRouter = new Hono();
|
||||
|
||||
const REDACTED = "••••••••";
|
||||
|
||||
const putAuthProviderSchema = z.object({
|
||||
providerId: z.string().min(1).max(100),
|
||||
displayName: z.string().min(1).max(200),
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
scopes: z.string().default("openid profile email"),
|
||||
});
|
||||
|
||||
/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */
|
||||
const authProviderTestSchema = z.object({
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/auth-provider
|
||||
* Returns the current provider config with clientSecret redacted.
|
||||
* Returns 404 if no provider is configured.
|
||||
*/
|
||||
authProviderRouter.get(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: "No auth provider configured" }, 404);
|
||||
}
|
||||
|
||||
// Return with secret redacted
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
clientSecret: REDACTED,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/admin/auth-provider
|
||||
* Creates or replaces the auth provider config.
|
||||
* The clientSecret is encrypted before storage.
|
||||
*/
|
||||
authProviderRouter.put(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
zValidator("json", putAuthProviderSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
let encryptedSecret: string;
|
||||
try {
|
||||
encryptedSecret = encryptSecret(body.clientSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to encrypt client secret: ${message}` }, 500);
|
||||
}
|
||||
|
||||
// Upsert: delete existing rows then insert atomically
|
||||
let row: typeof authProviderConfig.$inferSelect | undefined;
|
||||
try {
|
||||
[row] = await db.transaction(async (tx) => {
|
||||
await tx.delete(authProviderConfig);
|
||||
return tx.insert(authProviderConfig).values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
}).returning();
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to persist auth provider config: ${message}` }, 500);
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Failed to create auth provider config" }, 500);
|
||||
|
||||
try {
|
||||
await reinitAuth();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
clientSecret: REDACTED,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/auth-provider/test
|
||||
* Validates the provider config by hitting the OIDC discovery endpoint.
|
||||
* Returns {ok: true, metadata} on success or {ok: false, error: string} on failure.
|
||||
*/
|
||||
authProviderRouter.post(
|
||||
"/test",
|
||||
requireSuperUser(),
|
||||
zValidator("json", authProviderTestSchema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const discoveryUrl = `${body.issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) {
|
||||
return c.json({ ok: false, error: `Discovery endpoint returned ${res.status}` });
|
||||
}
|
||||
const metadata = await res.json() as Record<string, unknown>;
|
||||
return c.json({ ok: true, metadata });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ ok: false, error: message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/auth-provider
|
||||
* Removes the auth provider config from the DB.
|
||||
* After this, auth falls back to OIDC_* env vars.
|
||||
*/
|
||||
authProviderRouter.delete(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
await db.delete(authProviderConfig);
|
||||
try {
|
||||
await reinitAuth();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
lt,
|
||||
ne,
|
||||
getDb,
|
||||
services,
|
||||
staff,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
} from "@groombook/db";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
BUSINESS_START_HOUR,
|
||||
BUSINESS_END_HOUR,
|
||||
} from "../lib/slots.js";
|
||||
|
||||
export const bookRouter = new Hono();
|
||||
|
||||
// ─── GET /api/book/services ─────────────────────────────────────────────────
|
||||
// Public: list active services for the booking flow
|
||||
|
||||
bookRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.active, true))
|
||||
.orderBy(services.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD)
|
||||
|
||||
bookRouter.get("/availability", async (c) => {
|
||||
const serviceId = c.req.query("serviceId");
|
||||
const dateStr = c.req.query("date");
|
||||
|
||||
if (!serviceId || !dateStr) {
|
||||
return c.json({ error: "serviceId and date are required" }, 400);
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return c.json({ error: "date must be YYYY-MM-DD" }, 400);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [service] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
|
||||
|
||||
if (groomers.length === 0) return c.json([]);
|
||||
|
||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
// Fetch all active appointments for the day (any groomer)
|
||||
const booked = await db
|
||||
.select({
|
||||
staffId: appointments.staffId,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, dayStart),
|
||||
lt(appointments.startTime, dayEnd),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
);
|
||||
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes: service.durationMinutes,
|
||||
groomerIds: groomers.map((g) => g.id),
|
||||
booked,
|
||||
});
|
||||
|
||||
return c.json(slots);
|
||||
});
|
||||
|
||||
// ─── POST /api/book/appointments ─────────────────────────────────────────────
|
||||
// Public: create a booking. Finds or creates client by email, always creates pet.
|
||||
|
||||
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" }
|
||||
),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientEmail: z.string().email(),
|
||||
clientPhone: z.string().max(50).optional(),
|
||||
petName: z.string().min(1).max(200),
|
||||
petSpecies: z.string().min(1).max(100),
|
||||
petBreed: z.string().max(100).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
bookRouter.post(
|
||||
"/appointments",
|
||||
zValidator("json", bookingSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const start = new Date(body.startTime);
|
||||
|
||||
const [service] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
|
||||
// Find all active groomers
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
|
||||
|
||||
if (groomers.length === 0) {
|
||||
return c.json({ error: "No groomers available" }, 409);
|
||||
}
|
||||
|
||||
// Find conflicting appointments for this time window
|
||||
const booked = await db
|
||||
.select({ staffId: appointments.staffId })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
lt(appointments.startTime, end),
|
||||
gt(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
);
|
||||
|
||||
const busyIds = new Set(booked.map((a) => a.staffId));
|
||||
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
|
||||
if (!freeGroomer) {
|
||||
return c.json(
|
||||
{ error: "No groomers available at this time. Please choose another slot." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
// Find or create client by email (skip disabled clients)
|
||||
let [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active")));
|
||||
|
||||
if (!client) {
|
||||
const inserted = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
name: body.clientName,
|
||||
email: body.clientEmail,
|
||||
phone: body.clientPhone ?? null,
|
||||
})
|
||||
.returning();
|
||||
client = inserted[0];
|
||||
}
|
||||
|
||||
if (!client) return c.json({ error: "Failed to create client" }, 500);
|
||||
|
||||
// Create pet
|
||||
const petInserted = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId: client.id,
|
||||
name: body.petName,
|
||||
species: body.petSpecies,
|
||||
breed: body.petBreed ?? null,
|
||||
})
|
||||
.returning();
|
||||
const pet = petInserted[0];
|
||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||
|
||||
// Insert appointment in a transaction to guard against race conditions
|
||||
let appointment;
|
||||
try {
|
||||
appointment = await db.transaction(async (tx) => {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, freeGroomer.id),
|
||||
lt(appointments.startTime, end),
|
||||
gt(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 });
|
||||
}
|
||||
|
||||
const apptInserted = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: body.serviceId,
|
||||
staffId: freeGroomer.id,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
notes: body.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
return apptInserted[0];
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const code = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (code === 409) {
|
||||
return c.json(
|
||||
{ error: "This slot was just taken. Please choose another time." },
|
||||
409
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!appointment) return c.json({ error: "Failed to create appointment" }, 500);
|
||||
|
||||
return c.json({ appointment, client, pet }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/book/confirm/:token ──────────────────────────────────────────
|
||||
// Public: confirm appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173";
|
||||
|
||||
bookRouter.get("/confirm/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
|
||||
// ─── GET /api/book/cancel/:token ───────────────────────────────────────────
|
||||
// Public: cancel appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
bookRouter.get("/cancel/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
getDb,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
|
||||
export const calendarRouter = new Hono();
|
||||
|
||||
function formatIcalDate(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
||||
}
|
||||
|
||||
function escapeIcalText(text: string | null): string {
|
||||
if (!text) return "";
|
||||
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
||||
}
|
||||
|
||||
function buildIcalFeed(
|
||||
appointments: Array<{
|
||||
id: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
status: string;
|
||||
clientName: string | null;
|
||||
petName: string | null;
|
||||
serviceName: string | null;
|
||||
}>,
|
||||
staffName: string,
|
||||
dtstamp: string
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//GroomBook//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
`X-WR-CALNAME:${escapeIcalText(staffName)} - GroomBook`,
|
||||
];
|
||||
|
||||
for (const appt of appointments) {
|
||||
const status = appt.status === "cancelled" ? "CANCELLED" : "CONFIRMED";
|
||||
const sequence = appt.status === "cancelled" ? "1" : "0";
|
||||
const summary = `${appt.petName ?? "Pet"} - ${appt.serviceName ?? "Appointment"}`;
|
||||
const description = `Client: ${appt.clientName ?? "Unknown"}\nPet: ${appt.petName ?? "Unknown"}\nService: ${appt.serviceName ?? "Unknown"}`;
|
||||
|
||||
lines.push(
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${appt.id}@groombook`,
|
||||
`DTSTAMP:${dtstamp}`,
|
||||
`DTSTART:${formatIcalDate(new Date(appt.startTime))}`,
|
||||
`DTEND:${formatIcalDate(new Date(appt.endTime))}`,
|
||||
`SUMMARY:${escapeIcalText(summary)}`,
|
||||
`DESCRIPTION:${escapeIcalText(description)}`,
|
||||
`STATUS:${status}`,
|
||||
`SEQUENCE:${sequence}`,
|
||||
"END:VEVENT"
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
calendarRouter.get("/:staffId.ics", async (c) => {
|
||||
const db = getDb();
|
||||
const staffId = c.req.param("staffId") as string;
|
||||
const token = c.req.query("token") as string;
|
||||
|
||||
if (!token) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const [staffMember] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, staffId))
|
||||
.limit(1);
|
||||
|
||||
if (!staffMember || !staffMember.icalToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const storedToken = staffMember.icalToken;
|
||||
const incomingToken = token;
|
||||
const storedBuf = Buffer.from(storedToken, "utf8");
|
||||
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
||||
if (
|
||||
storedBuf.length !== incomingBuf.length ||
|
||||
!timingSafeEqual(storedBuf, incomingBuf)
|
||||
) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
clientName: clients.name,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, staffId),
|
||||
gte(appointments.startTime, now)
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
const ical = buildIcalFeed(rows, staffMember.name, formatIcalDate(new Date()));
|
||||
return c.text(ical, 200, {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": `inline; filename="${encodeURIComponent(staffMember.name)}_calendar.ics"`,
|
||||
});
|
||||
});
|
||||
|
||||
export function generateIcalToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
smsOptIn: z.boolean().optional(),
|
||||
smsConsentText: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
|
||||
// List clients — defaults to active only, ?includeDisabled=true shows all.
|
||||
// Groomers see only clients with ≥1 appointment assigned to them.
|
||||
clientsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeDisabled = c.req.query("includeDisabled") === "true";
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Groomer: subquery for clients with an appointment for this groomer
|
||||
const groomerApptFilter = isGroomer
|
||||
? exists(
|
||||
db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clients.id),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const conditions = [];
|
||||
if (!includeDisabled) conditions.push(eq(clients.status, "active"));
|
||||
if (groomerApptFilter) conditions.push(groomerApptFilter);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(clients.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// Get a single client
|
||||
clientsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if no appointment linkage to this client
|
||||
if (isGroomer) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
// Create a client
|
||||
clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(clients).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
// Update a client (including status changes)
|
||||
const patchClientSchema = createClientSchema.partial().extend({
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
smsOptOut: z.boolean().optional(),
|
||||
});
|
||||
|
||||
clientsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", patchClientSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const now = new Date();
|
||||
|
||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||
|
||||
if (body.status === "disabled") {
|
||||
setValues.disabledAt = now;
|
||||
} else if (body.status === "active") {
|
||||
setValues.disabledAt = null;
|
||||
}
|
||||
|
||||
if (body.smsOptOut === true) {
|
||||
setValues.smsOptIn = false;
|
||||
setValues.smsOptOutDate = now;
|
||||
delete setValues.smsOptOut;
|
||||
}
|
||||
delete setValues.smsOptOut;
|
||||
|
||||
const [row] = await db
|
||||
.update(clients)
|
||||
.set(setValues)
|
||||
.where(eq(clients.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a client — requires ?confirm=true query param
|
||||
clientsRouter.delete("/:id", async (c) => {
|
||||
const confirm = c.req.query("confirm");
|
||||
if (confirm !== "true") {
|
||||
return c.json(
|
||||
{ error: "Permanent deletion requires ?confirm=true. Consider disabling the client instead." },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
|
||||
const [existingAppt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (existingAppt) {
|
||||
return c.json(
|
||||
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.delete(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Hono } from "hono";
|
||||
import { getDb, staff, clients, eq, sql } from "@groombook/db";
|
||||
|
||||
const devRouter = new Hono();
|
||||
|
||||
// GET /api/dev/config — tells the frontend whether auth is disabled
|
||||
devRouter.get("/config", (c) => {
|
||||
return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" });
|
||||
});
|
||||
|
||||
// GET /api/dev/users — list staff and clients for the login selector
|
||||
// Only available when AUTH_DISABLED=true
|
||||
devRouter.get("/users", async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const staffList = await db
|
||||
.select({
|
||||
id: staff.id,
|
||||
userId: staff.userId,
|
||||
name: staff.name,
|
||||
email: staff.email,
|
||||
role: staff.role,
|
||||
})
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.orderBy(staff.name);
|
||||
|
||||
const clientList = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
petCount: sql<number>`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"),
|
||||
})
|
||||
.from(clients)
|
||||
.orderBy(clients.name)
|
||||
.limit(20);
|
||||
|
||||
return c.json({ staff: staffList, clients: clientList });
|
||||
});
|
||||
|
||||
export { devRouter };
|
||||
@@ -0,0 +1,143 @@
|
||||
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";
|
||||
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createLogSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
cutStyle: z.string().max(500).optional(),
|
||||
productsUsed: z.string().max(1000).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
groomedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// GET /api/grooming-logs?petId=<uuid>
|
||||
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)
|
||||
.where(eq(groomingVisitLogs.petId, petId))
|
||||
.orderBy(desc(groomingVisitLogs.groomedAt));
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
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 [row] = await db
|
||||
.insert(groomingVisitLogs)
|
||||
.values({
|
||||
...rest,
|
||||
petId,
|
||||
appointmentId: appointmentId ?? null,
|
||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
.delete(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.returning();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs,
|
||||
clients,
|
||||
desc,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const impersonationRouter = new Hono<AppEnv>();
|
||||
|
||||
const SESSION_TIMEOUT_MINUTES = 30;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) {
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
/** Expire any timed-out active sessions for a given staff member. */
|
||||
async function expireTimedOutSessions(staffId: string) {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const active = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.staffId, staffId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
);
|
||||
for (const s of active) {
|
||||
if (s.expiresAt <= now) {
|
||||
await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "expired", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, s.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an active session has expired by time. If so, mark it expired in DB
|
||||
* and return true. Returns false if the session is still valid.
|
||||
*/
|
||||
async function checkAndExpireSession(
|
||||
session: typeof impersonationSessions.$inferSelect
|
||||
): Promise<boolean> {
|
||||
if (session.status !== "active") return false;
|
||||
if (session.expiresAt > new Date()) return false;
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "expired", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, session.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── POST /sessions — Start a new impersonation session ─────────────────────
|
||||
// requireRole("manager") is enforced by index.ts middleware on /impersonation/*
|
||||
|
||||
const startSessionSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
impersonationRouter.post(
|
||||
"/sessions",
|
||||
zValidator("json", startSessionSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId));
|
||||
if (!client) return c.json({ error: "Client not found" }, 404);
|
||||
|
||||
// Expire timed-out sessions first
|
||||
await expireTimedOutSessions(staffRow.id);
|
||||
|
||||
// Enforce one active session per staff member
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.staffId, staffRow.id),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
);
|
||||
if (existing) {
|
||||
return c.json(
|
||||
{ error: "You already have an active impersonation session", sessionId: existing.id },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId: staffRow.id,
|
||||
clientId: body.clientId,
|
||||
reason: body.reason ?? null,
|
||||
expiresAt: expiresAt(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Log session start
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session!.id,
|
||||
action: "session_started",
|
||||
metadata: { reason: body.reason ?? null },
|
||||
});
|
||||
|
||||
return c.json(session!, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /sessions/:id — Get session details ────────────────────────────────
|
||||
|
||||
impersonationRouter.get("/sessions/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
|
||||
// Auto-expire if timed out
|
||||
if (await checkAndExpireSession(session)) {
|
||||
session.status = "expired";
|
||||
session.endedAt = new Date();
|
||||
}
|
||||
|
||||
return c.json(session);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/extend — Extend session timeout ─────────────────────
|
||||
|
||||
impersonationRouter.post("/sessions/:id/extend", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const newExpiry = expiresAt();
|
||||
const [updated] = await db
|
||||
.update(impersonationSessions)
|
||||
.set({ expiresAt: newExpiry })
|
||||
.where(eq(impersonationSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session.id,
|
||||
action: "session_extended",
|
||||
metadata: { newExpiresAt: newExpiry.toISOString() },
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/end — End session ────────────────────────────────────
|
||||
|
||||
impersonationRouter.post("/sessions/:id/end", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "ended", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session.id,
|
||||
action: "session_ended",
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/log — Log an audit entry ────────────────────────────
|
||||
|
||||
const logEntrySchema = z.object({
|
||||
action: z.string().min(1).max(200),
|
||||
pageVisited: z.string().max(500).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
impersonationRouter.post(
|
||||
"/sessions/:id/log",
|
||||
zValidator("json", logEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId: session.id,
|
||||
action: body.action,
|
||||
pageVisited: body.pageVisited ?? null,
|
||||
metadata: body.metadata ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /sessions/:id/audit-log — Get audit trail ──────────────────────────
|
||||
|
||||
impersonationRouter.get("/sessions/:id/audit-log", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(impersonationAuditLogs)
|
||||
.where(eq(impersonationAuditLogs.sessionId, session.id))
|
||||
.orderBy(desc(impersonationAuditLogs.createdAt));
|
||||
|
||||
return c.json(logs);
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
invoices,
|
||||
invoiceLineItems,
|
||||
invoiceTipSplits,
|
||||
refunds,
|
||||
appointments,
|
||||
services,
|
||||
clients,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
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(),
|
||||
lineItems: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string().min(1).max(500),
|
||||
quantity: z.number().int().positive().default(1),
|
||||
unitPriceCents: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
taxCents: z.number().int().nonnegative().default(0),
|
||||
tipCents: z.number().int().nonnegative().default(0),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z.object({
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
paymentMethod: z.enum(["cash", "card", "check", "other"]).nullable().optional(),
|
||||
paidAt: z.string().datetime().nullable().optional(),
|
||||
taxCents: z.number().int().nonnegative().optional(),
|
||||
tipCents: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
tipSplits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).optional(),
|
||||
});
|
||||
|
||||
// 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(
|
||||
"/",
|
||||
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,
|
||||
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||
stripeRefundId: invoices.stripeRefundId,
|
||||
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();
|
||||
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);
|
||||
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
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 });
|
||||
});
|
||||
|
||||
// Save tip splits for an invoice (replaces existing splits)
|
||||
const tipSplitSchema = z.object({
|
||||
splits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).min(1).refine(
|
||||
(splits) => {
|
||||
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||
return totalBps === 10000;
|
||||
},
|
||||
{ message: "Split percentages must sum to 100" }
|
||||
),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/tip-splits",
|
||||
zValidator("json", tipSplitSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status === "void") return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
||||
|
||||
const tipCents = invoice.tipCents;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Remove existing splits
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
|
||||
// Insert new splits, distributing tipCents proportionally
|
||||
let remaining = tipCents;
|
||||
const rows = body.splits.map((s, i) => {
|
||||
const isLast = i === body.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,
|
||||
};
|
||||
});
|
||||
|
||||
if (rows.length > 0) {
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
});
|
||||
|
||||
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Create invoice (optionally pre-populated from an appointment)
|
||||
invoicesRouter.post(
|
||||
"/",
|
||||
zValidator("json", createInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// If appointmentId provided, verify it exists
|
||||
if (body.appointmentId) {
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, body.appointmentId));
|
||||
if (!appt) return c.json({ error: "Appointment not found" }, 404);
|
||||
}
|
||||
|
||||
const subtotalCents = body.lineItems.reduce(
|
||||
(sum, item) => sum + item.quantity * item.unitPriceCents,
|
||||
0
|
||||
);
|
||||
const totalCents = subtotalCents + body.taxCents + body.tipCents;
|
||||
|
||||
const [invoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
appointmentId: body.appointmentId ?? null,
|
||||
clientId: body.clientId,
|
||||
subtotalCents,
|
||||
taxCents: body.taxCents,
|
||||
tipCents: body.tipCents,
|
||||
totalCents,
|
||||
notes: body.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!invoice) return c.json({ error: "Failed to create invoice" }, 500);
|
||||
|
||||
const items = await db
|
||||
.insert(invoiceLineItems)
|
||||
.values(
|
||||
body.lineItems.map((item) => ({
|
||||
invoiceId: invoice.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
totalCents: item.quantity * item.unitPriceCents,
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return c.json({ ...invoice, lineItems: items }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Create invoice from appointment (convenience endpoint)
|
||||
invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
||||
const db = getDb();
|
||||
const appointmentId = c.req.param("appointmentId");
|
||||
|
||||
const [appt] = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
clientId: appointments.clientId,
|
||||
serviceId: appointments.serviceId,
|
||||
priceCents: appointments.priceCents,
|
||||
serviceName: services.name,
|
||||
serviceBasePriceCents: services.basePriceCents,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(eq(appointments.id, appointmentId));
|
||||
|
||||
if (!appt) return c.json({ error: "Appointment not found" }, 404);
|
||||
|
||||
// Check if invoice already exists for this appointment
|
||||
const [existing] = await db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.appointmentId, appointmentId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return c.json(
|
||||
{ error: "Invoice already exists for this appointment", invoiceId: existing.id },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const unitPriceCents = appt.priceCents ?? appt.serviceBasePriceCents;
|
||||
const subtotalCents = unitPriceCents;
|
||||
const totalCents = subtotalCents;
|
||||
|
||||
const [invoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
appointmentId,
|
||||
clientId: appt.clientId,
|
||||
subtotalCents,
|
||||
taxCents: 0,
|
||||
tipCents: 0,
|
||||
totalCents,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!invoice) return c.json({ error: "Failed to create invoice" }, 500);
|
||||
|
||||
const [lineItem] = await db
|
||||
.insert(invoiceLineItems)
|
||||
.values({
|
||||
invoiceId: invoice.id,
|
||||
description: appt.serviceName,
|
||||
quantity: 1,
|
||||
unitPriceCents,
|
||||
totalCents: unitPriceCents,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
||||
});
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
|
||||
draft: ["pending", "void"],
|
||||
pending: ["draft", "paid", "void"],
|
||||
paid: ["void"],
|
||||
void: [],
|
||||
};
|
||||
|
||||
// Update invoice
|
||||
invoicesRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, id));
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (body.status !== undefined) {
|
||||
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
||||
if (!allowed.includes(body.status)) {
|
||||
return c.json(
|
||||
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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() };
|
||||
|
||||
// Auto-set paidAt when marking as paid
|
||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||
update.paidAt = new Date();
|
||||
}
|
||||
|
||||
// Recalculate total if tax or tip changed
|
||||
const newTaxCents = body.taxCents ?? current.taxCents;
|
||||
const newTipCents = body.tipCents ?? current.tipCents;
|
||||
if (body.taxCents !== undefined || body.tipCents !== undefined) {
|
||||
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
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
const lineItems = await tx
|
||||
.select()
|
||||
.from(invoiceLineItems)
|
||||
.where(eq(invoiceLineItems.invoiceId, id));
|
||||
|
||||
return [updatedInvoice, lineItems];
|
||||
});
|
||||
|
||||
return c.json({ ...updated, lineItems });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||
|
||||
const refundSchema = z.object({
|
||||
amountCents: z.number().int().nonnegative().optional(),
|
||||
idempotencyKey: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/refund",
|
||||
zValidator("json", refundSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staff = c.get("staff");
|
||||
if (!staff) return c.json({ error: "Forbidden" }, 403);
|
||||
if (staff.role !== "manager" && !staff.isSuperUser) {
|
||||
return c.json({ error: "Manager role required" }, 403);
|
||||
}
|
||||
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status !== "paid") {
|
||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
if (body.idempotencyKey) {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(refunds)
|
||||
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
||||
if (existing) {
|
||||
return c.json({ refundId: existing.stripeRefundId });
|
||||
}
|
||||
}
|
||||
|
||||
let refundId: string;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
refundId = result.refundId;
|
||||
} else {
|
||||
// Manual refund — no Stripe call needed
|
||||
refundId = `manual_${id}_${Date.now()}`;
|
||||
}
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
invoiceId: id,
|
||||
stripeRefundId: refundId,
|
||||
idempotencyKey: body.idempotencyKey ?? null,
|
||||
amountCents: body.amountCents ?? null,
|
||||
});
|
||||
|
||||
return c.json({ refundId });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Payment stats for admin dashboard
|
||||
invoicesRouter.get("/stats/summary", 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
getPresignedGetUrl,
|
||||
deleteObject,
|
||||
} from "../lib/s3.js";
|
||||
|
||||
export const petsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createPetSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
name: z.string().min(1).max(200),
|
||||
species: z.string().min(1).max(100),
|
||||
breed: z.string().max(200).optional(),
|
||||
weightKg: z.number().positive().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
healthAlerts: z.string().max(2000).optional(),
|
||||
groomingNotes: z.string().max(2000).optional(),
|
||||
cutStyle: z.string().max(500).optional(),
|
||||
shampooPreference: z.string().max(500).optional(),
|
||||
specialCareNotes: z.string().max(2000).optional(),
|
||||
customFields: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||
|
||||
// List pets, optionally filtered by clientId.
|
||||
// Groomers see only pets owned by clients with ≥1 appointment for this groomer.
|
||||
petsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Groomer: filter to pets whose client has an appointment for this groomer
|
||||
const groomerClientFilter = isGroomer
|
||||
? exists(
|
||||
db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, pets.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(pets.clientId, clientId));
|
||||
if (groomerClientFilter) conditions.push(groomerClientFilter);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
petsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.id, petId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if no appointment linkage to this pet's client
|
||||
if (isGroomer) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, row.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
customFields: customFields ?? {},
|
||||
})
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
petsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updatePetSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(pets)
|
||||
.set({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
...(customFields !== undefined ? { customFields } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pets.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
petsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(pets)
|
||||
.where(eq(pets.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Photo routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
const uploadUrlSchema = z.object({
|
||||
contentType: z.string().refine((v) => ALLOWED_CONTENT_TYPES.has(v), {
|
||||
message: "contentType must be one of: image/jpeg, image/png, image/webp, image/gif",
|
||||
}),
|
||||
fileSizeBytes: z.number().int().positive().max(MAX_PHOTO_SIZE, {
|
||||
message: "File must not exceed 5 MB",
|
||||
}),
|
||||
});
|
||||
|
||||
const confirmSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:petId/photo/upload-url
|
||||
* Returns a presigned S3 PUT URL and the object key for the upload.
|
||||
* All staff roles (manager, receptionist, groomer) may call this.
|
||||
*/
|
||||
petsRouter.post(
|
||||
"/:petId/photo/upload-url",
|
||||
zValidator("json", uploadUrlSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
const { contentType, fileSizeBytes } = c.req.valid("json");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
const ext = contentType.split("/")[1] ?? "jpg";
|
||||
const key = `pets/${petId}/${Date.now()}.${ext}`;
|
||||
const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes);
|
||||
|
||||
return c.json({ uploadUrl, key });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /:petId/photo/confirm
|
||||
* Called after the client has successfully uploaded to the presigned URL.
|
||||
* Records the object key in the DB.
|
||||
*/
|
||||
petsRouter.post(
|
||||
"/:petId/photo/confirm",
|
||||
zValidator("json", confirmSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
const { key } = c.req.valid("json");
|
||||
|
||||
// Validate that the key belongs to this pet to prevent key hijacking
|
||||
if (!key.startsWith(`pets/${petId}/`)) {
|
||||
return c.json({ error: "Invalid key" }, 400);
|
||||
}
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(pets)
|
||||
.set({ photoKey: key, photoUploadedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(pets.id, petId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
return c.json({ ok: true, photoKey: row.photoKey });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /:petId/photo
|
||||
* Removes the photo from object storage and clears the DB record.
|
||||
* All staff roles (manager, receptionist, groomer) may call this.
|
||||
*/
|
||||
petsRouter.delete("/:petId/photo", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
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 db
|
||||
.update(pets)
|
||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||
.where(eq(pets.id, petId));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:petId/photo
|
||||
* Returns a presigned GET URL for the pet's photo.
|
||||
* All authenticated staff may access (read).
|
||||
*/
|
||||
petsRouter.get("/:petId/photo", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||
|
||||
const url = await getPresignedGetUrl(pet.photoKey);
|
||||
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
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);
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
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);
|
||||
|
||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||
});
|
||||
|
||||
portalRouter.get("/config", async (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const allAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
confirmationStatus: appointments.confirmationStatus,
|
||||
customerNotes: appointments.customerNotes,
|
||||
notes: appointments.notes,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
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 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 petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||
|
||||
const appts = allAppts.map(a => ({
|
||||
id: a.id,
|
||||
startTime: a.startTime,
|
||||
endTime: a.endTime,
|
||||
status: a.status,
|
||||
confirmationStatus: a.confirmationStatus,
|
||||
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,
|
||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||
}));
|
||||
|
||||
return c.json({ appointments: appts });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
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 })));
|
||||
});
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
|
||||
|
||||
const itemsByInvoice: Record<string, typeof lineItems> = {};
|
||||
for (const li of lineItems) {
|
||||
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
|
||||
itemsByInvoice[li.invoiceId]!.push(li);
|
||||
}
|
||||
|
||||
return c.json(clientInvoices.map(inv => ({
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
totalCents: inv.totalCents,
|
||||
date: inv.createdAt,
|
||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||
})));
|
||||
});
|
||||
|
||||
// ─── 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),
|
||||
});
|
||||
|
||||
portalRouter.patch(
|
||||
"/appointments/:id/notes",
|
||||
zValidator("json", customerNotesSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot edit notes for past or in-progress appointments" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ customerNotes: body.customerNotes, updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
customerNotes: updated.customerNotes,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Appointment confirm/cancel ──────────────────────────────────────────────
|
||||
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus !== "pending") {
|
||||
return c.json({ error: "Appointment is not pending confirmation" }, 422);
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated!.id,
|
||||
confirmationStatus: updated!.confirmationStatus,
|
||||
confirmedAt: updated!.confirmedAt,
|
||||
updatedAt: updated!.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422);
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Appointment is already cancelled or completed" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated!.id,
|
||||
status: updated!.status,
|
||||
confirmationStatus: updated!.confirmationStatus,
|
||||
cancelledAt: updated!.cancelledAt,
|
||||
updatedAt: updated!.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
preferredDate: z.string(),
|
||||
preferredTime: z.string(),
|
||||
});
|
||||
|
||||
const updateWaitlistEntrySchema = z.object({
|
||||
status: z.literal("cancelled").optional(),
|
||||
preferredDate: z.string().optional(),
|
||||
preferredTime: z.string().optional(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/waitlist",
|
||||
zValidator("json", createWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
.values({
|
||||
clientId,
|
||||
petId: body.petId,
|
||||
serviceId: body.serviceId,
|
||||
preferredDate: body.preferredDate,
|
||||
preferredTime: body.preferredTime,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.patch(
|
||||
"/waitlist/:id",
|
||||
zValidator("json", updateWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.status !== undefined) updateData.status = body.status;
|
||||
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
|
||||
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
|
||||
|
||||
const [updated] = await db
|
||||
.update(waitlistEntries)
|
||||
.set(updateData)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Payment routes ───────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
createPaymentIntent,
|
||||
listPaymentMethods,
|
||||
detachPaymentMethod,
|
||||
createSetupIntent,
|
||||
getOrCreateStripeCustomer,
|
||||
getStripeClient,
|
||||
} from "../services/payment.js";
|
||||
|
||||
const payMultipleSchema = z.object({
|
||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/invoices/pay-multiple",
|
||||
zValidator("json", payMultipleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, body.invoiceIds));
|
||||
|
||||
if (invoiceRows.length !== body.invoiceIds.length) {
|
||||
return c.json({ error: "One or more invoices not found" }, 404);
|
||||
}
|
||||
|
||||
for (const inv of invoiceRows) {
|
||||
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||
if (inv.status === "draft" || inv.status === "void") {
|
||||
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
|
||||
}
|
||||
if (inv.status === "paid") {
|
||||
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
|
||||
}
|
||||
}
|
||||
|
||||
const firstInvoice = invoiceRows[0];
|
||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||
if (!allSameClient) {
|
||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||
}
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const result = await createPaymentIntent(body.invoiceIds, clientId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
return c.json(methods);
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
|
||||
|
||||
const result = await createSetupIntent(customerId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
||||
return c.json({ error: "Payment method not found" }, 404);
|
||||
}
|
||||
|
||||
const ok = await detachPaymentMethod(paymentMethodId);
|
||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
lt,
|
||||
sql,
|
||||
getDb,
|
||||
appointments,
|
||||
clients,
|
||||
invoices,
|
||||
invoiceTipSplits,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
|
||||
export const reportsRouter = new Hono();
|
||||
|
||||
reportsRouter.onError((err, c) => {
|
||||
console.error("[reports] unhandled error:", err);
|
||||
return c.json({ error: "Internal server error", message: err.message }, 500);
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseDate(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? fallback : d;
|
||||
}
|
||||
|
||||
function defaultFrom(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() - 30);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function defaultTo(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
// GET /api/reports/summary?from=&to=
|
||||
// High-level KPIs for a date range
|
||||
|
||||
reportsRouter.get("/summary", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
const [revenueRow] = await db
|
||||
.select({
|
||||
totalRevenueCents: sql<number>`COALESCE(SUM(${invoices.totalCents}), 0)::int`,
|
||||
paidCount: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
);
|
||||
|
||||
const [apptRow] = await db
|
||||
.select({
|
||||
total: sql<number>`COUNT(*)::int`,
|
||||
completed: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
cancelled: sql<number>`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`,
|
||||
noShow: sql<number>`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
);
|
||||
|
||||
const [clientRow] = await db
|
||||
.select({
|
||||
totalClients: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(clients);
|
||||
|
||||
// New clients in the period
|
||||
const [newClientRow] = await db
|
||||
.select({
|
||||
newClients: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
gte(clients.createdAt, from),
|
||||
lt(clients.createdAt, to)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
revenue: {
|
||||
totalCents: revenueRow?.totalRevenueCents ?? 0,
|
||||
paidInvoices: revenueRow?.paidCount ?? 0,
|
||||
},
|
||||
appointments: {
|
||||
total: apptRow?.total ?? 0,
|
||||
completed: apptRow?.completed ?? 0,
|
||||
cancelled: apptRow?.cancelled ?? 0,
|
||||
noShow: apptRow?.noShow ?? 0,
|
||||
},
|
||||
clients: {
|
||||
total: clientRow?.totalClients ?? 0,
|
||||
new: newClientRow?.newClients ?? 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Revenue by period ────────────────────────────────────────────────────────
|
||||
// GET /api/reports/revenue?from=&to=&groupBy=day|week|month
|
||||
|
||||
reportsRouter.get("/revenue", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
const groupBy = c.req.query("groupBy") ?? "day";
|
||||
|
||||
const truncUnit =
|
||||
groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day";
|
||||
|
||||
const byPeriod = await db
|
||||
.select({
|
||||
period: sql<string>`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})::text`,
|
||||
totalCents: sql<number>`SUM(${invoices.totalCents})::int`,
|
||||
invoiceCount: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})`
|
||||
)
|
||||
.orderBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${invoices.paidAt})`
|
||||
);
|
||||
|
||||
// Revenue by groomer (via appointment -> staff join)
|
||||
const byGroomer = await db
|
||||
.select({
|
||||
staffId: staff.id,
|
||||
staffName: staff.name,
|
||||
totalCents: sql<number>`SUM(${invoices.totalCents})::int`,
|
||||
invoiceCount: sql<number>`COUNT(${invoices.id})::int`,
|
||||
})
|
||||
.from(invoices)
|
||||
.innerJoin(appointments, eq(invoices.appointmentId, appointments.id))
|
||||
.innerJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.groupBy(staff.id, staff.name)
|
||||
.orderBy(sql`SUM(${invoices.totalCents}) DESC`);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod, byGroomer });
|
||||
});
|
||||
|
||||
// ─── Appointment analytics ────────────────────────────────────────────────────
|
||||
// GET /api/reports/appointments?from=&to=&groupBy=day|week|month
|
||||
|
||||
reportsRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
const groupBy = c.req.query("groupBy") ?? "day";
|
||||
|
||||
const truncUnit =
|
||||
groupBy === "month" ? "month" : groupBy === "week" ? "week" : "day";
|
||||
|
||||
const byPeriod = await db
|
||||
.select({
|
||||
period: sql<string>`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})::text`,
|
||||
total: sql<number>`COUNT(*)::int`,
|
||||
completed: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
cancelled: sql<number>`SUM(CASE WHEN ${appointments.status} = 'cancelled' THEN 1 ELSE 0 END)::int`,
|
||||
noShow: sql<number>`SUM(CASE WHEN ${appointments.status} = 'no_show' THEN 1 ELSE 0 END)::int`,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})`
|
||||
)
|
||||
.orderBy(
|
||||
sql`DATE_TRUNC(${sql.raw(`'${truncUnit}'`)}, ${appointments.startTime})`
|
||||
);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), groupBy, byPeriod });
|
||||
});
|
||||
|
||||
// ─── Service popularity ───────────────────────────────────────────────────────
|
||||
// GET /api/reports/services?from=&to=
|
||||
|
||||
reportsRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
serviceId: services.id,
|
||||
serviceName: services.name,
|
||||
appointmentCount: sql<number>`COUNT(${appointments.id})::int`,
|
||||
completedCount: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
revenueCents: sql<number>`COALESCE(SUM(CASE WHEN ${invoices.status} = 'paid' THEN ${invoices.totalCents} ELSE 0 END), 0)::int`,
|
||||
})
|
||||
.from(services)
|
||||
.leftJoin(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.serviceId, services.id),
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.leftJoin(invoices, eq(invoices.appointmentId, appointments.id))
|
||||
.groupBy(services.id, services.name)
|
||||
.orderBy(sql`COUNT(${appointments.id}) DESC`);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), rows });
|
||||
});
|
||||
|
||||
// ─── Client retention ─────────────────────────────────────────────────────────
|
||||
// GET /api/reports/clients?from=&to=
|
||||
// Returns: new clients, returning clients, clients with no recent activity (churn risk)
|
||||
|
||||
reportsRouter.get("/clients", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
// New clients in period
|
||||
const newClients = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
clientName: clients.name,
|
||||
createdAt: clients.createdAt,
|
||||
})
|
||||
.from(clients)
|
||||
.where(and(gte(clients.createdAt, from), lt(clients.createdAt, to)))
|
||||
.orderBy(clients.createdAt);
|
||||
|
||||
// Active clients in period (had at least 1 appointment)
|
||||
const activeInPeriod = await db
|
||||
.select({
|
||||
clientId: appointments.clientId,
|
||||
appointmentCount: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to),
|
||||
eq(appointments.status, "completed")
|
||||
)
|
||||
)
|
||||
.groupBy(appointments.clientId);
|
||||
|
||||
// Clients with no appointment in last 90 days (churn risk)
|
||||
const ninetyDaysAgo = new Date();
|
||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
||||
|
||||
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const churnRisk = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
clientName: clients.name,
|
||||
lastAppointmentAt: sql<string | null>`MAX(${appointments.startTime})::text`,
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||
.groupBy(clients.id, clients.name)
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [churnCountRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(
|
||||
db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||
.groupBy(clients.id)
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.as("churn_count")
|
||||
);
|
||||
const churnRiskTotal = churnCountRow?.total ?? 0;
|
||||
|
||||
return c.json({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
newClients,
|
||||
activeInPeriodCount: activeInPeriod.length,
|
||||
churnRisk,
|
||||
churnRiskTotal,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tip splits payroll report ────────────────────────────────────────────────
|
||||
// GET /api/reports/tip-splits?from=&to=
|
||||
// Aggregates tip earnings per staff member for the period
|
||||
|
||||
reportsRouter.get("/tip-splits", async (c) => {
|
||||
const db = getDb();
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
staffId: invoiceTipSplits.staffId,
|
||||
staffName: invoiceTipSplits.staffName,
|
||||
totalTipCents: sql<number>`SUM(${invoiceTipSplits.shareCents})::int`,
|
||||
invoiceCount: sql<number>`COUNT(DISTINCT ${invoiceTipSplits.invoiceId})::int`,
|
||||
})
|
||||
.from(invoiceTipSplits)
|
||||
.innerJoin(invoices, eq(invoiceTipSplits.invoiceId, invoices.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.groupBy(invoiceTipSplits.staffId, invoiceTipSplits.staffName)
|
||||
.orderBy(sql`SUM(${invoiceTipSplits.shareCents}) DESC`);
|
||||
|
||||
return c.json({ from: from.toISOString(), to: to.toISOString(), rows });
|
||||
});
|
||||
|
||||
// ─── CSV export ───────────────────────────────────────────────────────────────
|
||||
// GET /api/reports/export.csv?type=revenue|appointments|services&from=&to=
|
||||
|
||||
reportsRouter.get("/export.csv", async (c) => {
|
||||
const db = getDb();
|
||||
const type = c.req.query("type") ?? "revenue";
|
||||
const from = parseDate(c.req.query("from"), defaultFrom());
|
||||
const to = parseDate(c.req.query("to"), defaultTo());
|
||||
|
||||
let csv = "";
|
||||
|
||||
if (type === "revenue") {
|
||||
const rows = await db
|
||||
.select({
|
||||
paidAt: invoices.paidAt,
|
||||
clientId: invoices.clientId,
|
||||
totalCents: invoices.totalCents,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(appointments, eq(invoices.appointmentId, appointments.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.status, "paid"),
|
||||
gte(invoices.paidAt, from),
|
||||
lt(invoices.paidAt, to)
|
||||
)
|
||||
)
|
||||
.orderBy(invoices.paidAt);
|
||||
|
||||
csv = "Date,Groomer,Total,Subtotal,Tax,Tip,Payment Method\n";
|
||||
csv += rows
|
||||
.map((r) =>
|
||||
[
|
||||
r.paidAt ? new Date(r.paidAt).toLocaleDateString() : "",
|
||||
r.staffName ?? "",
|
||||
(r.totalCents / 100).toFixed(2),
|
||||
(r.subtotalCents / 100).toFixed(2),
|
||||
(r.taxCents / 100).toFixed(2),
|
||||
(r.tipCents / 100).toFixed(2),
|
||||
r.paymentMethod ?? "",
|
||||
].join(",")
|
||||
)
|
||||
.join("\n");
|
||||
} else if (type === "appointments") {
|
||||
const rows = await db
|
||||
.select({
|
||||
startTime: appointments.startTime,
|
||||
status: appointments.status,
|
||||
clientId: appointments.clientId,
|
||||
clientName: clients.name,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.leftJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.leftJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
csv = "Date,Client,Service,Groomer,Status\n";
|
||||
csv += rows
|
||||
.map((r) =>
|
||||
[
|
||||
new Date(r.startTime).toLocaleDateString(),
|
||||
`"${(r.clientName ?? "").replace(/"/g, '""')}"`,
|
||||
`"${(r.serviceName ?? "").replace(/"/g, '""')}"`,
|
||||
r.staffName ?? "",
|
||||
r.status,
|
||||
].join(",")
|
||||
)
|
||||
.join("\n");
|
||||
} else if (type === "services") {
|
||||
const rows = await db
|
||||
.select({
|
||||
serviceName: services.name,
|
||||
appointmentCount: sql<number>`COUNT(${appointments.id})::int`,
|
||||
completedCount: sql<number>`SUM(CASE WHEN ${appointments.status} = 'completed' THEN 1 ELSE 0 END)::int`,
|
||||
})
|
||||
.from(services)
|
||||
.leftJoin(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.serviceId, services.id),
|
||||
gte(appointments.startTime, from),
|
||||
lt(appointments.startTime, to)
|
||||
)
|
||||
)
|
||||
.groupBy(services.id, services.name)
|
||||
.orderBy(sql`COUNT(${appointments.id}) DESC`);
|
||||
|
||||
csv = "Service,Total Appointments,Completed\n";
|
||||
csv += rows
|
||||
.map((r) =>
|
||||
[
|
||||
`"${r.serviceName.replace(/"/g, '""')}"`,
|
||||
r.appointmentCount,
|
||||
r.completedCount,
|
||||
].join(",")
|
||||
)
|
||||
.join("\n");
|
||||
} else {
|
||||
return c.json({ error: "Invalid type. Use revenue, appointments, or services." }, 400);
|
||||
}
|
||||
|
||||
const filename = `groombook-${type}-report.csv`;
|
||||
c.header("Content-Type", "text/csv");
|
||||
c.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
return c.text(csv);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Hono } from "hono";
|
||||
import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db";
|
||||
|
||||
export const searchRouter = new Hono();
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
/** Escape %, _, and \ in user input before wrapping with ILIKE wildcards. */
|
||||
function escapeLike(s: string): string {
|
||||
return `%${s.replace(/[%_\\]/g, "\\$&")}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search?q={query}
|
||||
*
|
||||
* Returns up to 10 matching active clients and up to 10 matching pets.
|
||||
* Clients are matched on name, email, or phone.
|
||||
* Pets are matched on name or breed; includes owner name.
|
||||
*/
|
||||
searchRouter.get("/", async (c) => {
|
||||
const q = c.req.query("q");
|
||||
if (!q || q.trim().length === 0) {
|
||||
return c.json({ error: "Query parameter q is required" }, 400);
|
||||
}
|
||||
|
||||
const pattern = escapeLike(q.trim());
|
||||
const db = getDb();
|
||||
|
||||
const [matchingClients, matchingPets] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
phone: clients.phone,
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.status, "active"),
|
||||
or(
|
||||
ilike(clients.name, pattern),
|
||||
ilike(clients.email, pattern),
|
||||
ilike(clients.phone, pattern)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(LIMIT),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: pets.id,
|
||||
name: pets.name,
|
||||
breed: pets.breed,
|
||||
clientId: pets.clientId,
|
||||
ownerName: clients.name,
|
||||
})
|
||||
.from(pets)
|
||||
.innerJoin(clients, and(eq(pets.clientId, clients.id), eq(clients.status, "active")))
|
||||
.where(
|
||||
or(
|
||||
ilike(pets.name, pattern),
|
||||
ilike(pets.breed, pattern)
|
||||
)
|
||||
)
|
||||
.limit(LIMIT),
|
||||
]);
|
||||
|
||||
return c.json({ clients: matchingClients, pets: matchingPets });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, services } from "@groombook/db";
|
||||
|
||||
export const servicesRouter = new Hono();
|
||||
|
||||
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),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const updateServiceSchema = createServiceSchema.partial();
|
||||
|
||||
servicesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
const query = db.select().from(services).orderBy(services.name);
|
||||
const rows = includeInactive
|
||||
? await query
|
||||
: await query.where(eq(services.active, true));
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
servicesRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
servicesRouter.post(
|
||||
"/",
|
||||
zValidator("json", createServiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(services).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
}
|
||||
);
|
||||
|
||||
servicesRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateServiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(services)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(services.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
servicesRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(services)
|
||||
.where(eq(services.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
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 { requireSuperUser } from "../middleware/rbac.js";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
|
||||
// GET /api/admin/settings — return current business settings
|
||||
settingsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
if (!row) {
|
||||
// Auto-create default settings if none exist
|
||||
const [created] = await db.insert(businessSettings).values({}).returning();
|
||||
return c.json(created);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const updateSettingsSchema = z.object({
|
||||
businessName: z.string().min(1).max(200).optional(),
|
||||
primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(),
|
||||
accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(),
|
||||
});
|
||||
|
||||
// PATCH /api/admin/settings — update business settings
|
||||
settingsRouter.patch(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
zValidator("json", updateSettingsSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Get or create the settings row
|
||||
const rows = await db.select().from(businessSettings).limit(1);
|
||||
let settingsId: string;
|
||||
if (rows[0]) {
|
||||
settingsId = rows[0].id;
|
||||
} else {
|
||||
const [inserted] = await db.insert(businessSettings).values({}).returning();
|
||||
if (!inserted) throw new Error("Failed to create default settings");
|
||||
settingsId = inserted.id;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(businessSettings)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, settingsId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Logo routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]);
|
||||
const MAX_LOGO_SIZE = 512 * 1024; // 512 KB
|
||||
|
||||
const logoUploadUrlSchema = z.object({
|
||||
contentType: z.string().refine((v) => ALLOWED_LOGO_TYPES.has(v), {
|
||||
message: "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
|
||||
}),
|
||||
fileSizeBytes: z.number().int().positive().max(MAX_LOGO_SIZE, {
|
||||
message: "File must not exceed 512 KB",
|
||||
}),
|
||||
});
|
||||
|
||||
const logoConfirmSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/logo/upload-url
|
||||
* Returns a presigned S3 PUT URL and the object key for logo upload.
|
||||
*/
|
||||
settingsRouter.post(
|
||||
"/logo/upload-url",
|
||||
zValidator("json", logoUploadUrlSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { contentType, fileSizeBytes } = c.req.valid("json");
|
||||
|
||||
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}`;
|
||||
const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes);
|
||||
|
||||
return c.json({ uploadUrl, key });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Records the object key in the DB and clears legacy base64 fields.
|
||||
*/
|
||||
settingsRouter.post(
|
||||
"/logo/confirm",
|
||||
zValidator("json", logoConfirmSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { key } = c.req.valid("json");
|
||||
|
||||
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;
|
||||
|
||||
// Validate key prefix
|
||||
if (!key.startsWith(`logos/${settingsId}/`)) {
|
||||
return c.json({ error: "Invalid key" }, 400);
|
||||
}
|
||||
|
||||
// Delete previous S3 object if any
|
||||
if (rows[0].logoKey) {
|
||||
await deleteObject(rows[0].logoKey);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
settingsRouter.get("/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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/settings/logo
|
||||
* Removes the logo from S3 and clears the DB record.
|
||||
*/
|
||||
settingsRouter.delete("/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);
|
||||
|
||||
await deleteObject(row.logoKey);
|
||||
await db
|
||||
.update(businessSettings)
|
||||
.set({ logoKey: null, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, row.id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
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();
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
||||
}
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - entry.count };
|
||||
}
|
||||
|
||||
export const setupRouter = new Hono<AppEnv>();
|
||||
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
// and whether the auth provider bootstrap step should be shown
|
||||
setupRouter.get("/status", async (c) => {
|
||||
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
|
||||
if (skipOobe) {
|
||||
return c.json({
|
||||
needsSetup: false,
|
||||
showAuthProviderStep: false,
|
||||
authConfigExists: false,
|
||||
authEnvVarsSet: false,
|
||||
skipped: true,
|
||||
});
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check if any super user exists
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
// Check if DB already has an auth provider config
|
||||
const [dbAuthConfig] = await db
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
// Check if OIDC env vars are set (bootstrap mode)
|
||||
const oidcIssuer = process.env.OIDC_ISSUER;
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID;
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
const authEnvVarsSet = !!(oidcIssuer && oidcClientId && oidcClientSecret);
|
||||
|
||||
return c.json({
|
||||
needsSetup: !superUser,
|
||||
// Show auth provider bootstrap step when: fresh install (no super user) AND no DB config AND no env vars
|
||||
showAuthProviderStep: !superUser && !dbAuthConfig && !authEnvVarsSet,
|
||||
authConfigExists: !!dbAuthConfig,
|
||||
authEnvVarsSet,
|
||||
});
|
||||
});
|
||||
|
||||
const setupSchema = z.object({
|
||||
businessName: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
// POST /api/setup — authenticated (Better-Auth JWT), creates staff record if needed and sets business name
|
||||
// This endpoint is exempt from resolveStaffMiddleware so that OOBE users (with no staff record yet) can complete setup
|
||||
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const jwt = c.get("jwtPayload");
|
||||
const currentStaff = c.get("staff"); // may be undefined during OOBE
|
||||
|
||||
// Use a transaction with row-level locking to prevent race conditions
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Lock super user rows to prevent concurrent claims
|
||||
// FOR UPDATE serializes concurrent claims: second transaction blocks until first commits
|
||||
const [existingSuperUser] = await tx
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
|
||||
if (existingSuperUser) {
|
||||
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
|
||||
}
|
||||
|
||||
// Lock the business_settings row for update to prevent concurrent setup
|
||||
const [existingSettings] = await tx
|
||||
.select({ id: businessSettings.id })
|
||||
.from(businessSettings)
|
||||
.limit(1);
|
||||
|
||||
// Update or create business settings with the business name
|
||||
if (existingSettings) {
|
||||
await tx
|
||||
.update(businessSettings)
|
||||
.set({ businessName: body.businessName, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, existingSettings.id));
|
||||
} else {
|
||||
await tx.insert(businessSettings).values({ businessName: body.businessName });
|
||||
}
|
||||
|
||||
// Find or create staff record for the authenticated user
|
||||
let resolvedStaff = currentStaff;
|
||||
|
||||
if (!resolvedStaff) {
|
||||
// Try to find by userId
|
||||
const [byUserId] = await tx
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, jwt.sub));
|
||||
if (byUserId) {
|
||||
resolvedStaff = byUserId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedStaff && jwt.email) {
|
||||
// Try auto-link by email: staff record exists with matching email but no userId
|
||||
const [byEmail] = await tx
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
||||
if (byEmail) {
|
||||
await tx
|
||||
.update(staff)
|
||||
.set({ userId: jwt.sub })
|
||||
.where(eq(staff.id, byEmail.id));
|
||||
resolvedStaff = { ...byEmail, userId: jwt.sub };
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedStaff) {
|
||||
// Brand new user during OOBE — create staff record
|
||||
if (!jwt.email) {
|
||||
return { error: "Cannot complete setup: authenticated user has no email claim", code: 400 };
|
||||
}
|
||||
const [newStaff] = await tx
|
||||
.insert(staff)
|
||||
.values({
|
||||
name: jwt.name || jwt.email,
|
||||
email: jwt.email,
|
||||
userId: jwt.sub,
|
||||
role: "manager",
|
||||
isSuperUser: false, // will be set below
|
||||
})
|
||||
.returning();
|
||||
resolvedStaff = newStaff!;
|
||||
}
|
||||
|
||||
// Mark as super user
|
||||
const [updatedStaff] = await tx
|
||||
.update(staff)
|
||||
.set({ isSuperUser: true, updatedAt: new Date() })
|
||||
.where(eq(staff.id, resolvedStaff.id))
|
||||
.returning();
|
||||
|
||||
return { staff: updatedStaff };
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
const status = (result as { code?: number }).code || 409;
|
||||
return c.json({ error: result.error }, status as any);
|
||||
}
|
||||
|
||||
return c.json({ ok: true, staff: result.staff }, 201);
|
||||
});
|
||||
|
||||
// ─── Auth Provider Bootstrap ──────────────────────────────────────────────────
|
||||
|
||||
const authProviderBootstrapSchema = z.object({
|
||||
providerId: z.string().min(1).max(100),
|
||||
displayName: z.string().min(1).max(200),
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
scopes: z.string().default("openid profile email"),
|
||||
});
|
||||
|
||||
// Minimal schema for test endpoint — OIDC discovery only needs issuer/internal URLs
|
||||
const authProviderTestSchema = z.object({
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/setup/auth-provider
|
||||
* Unauthenticated endpoint for first-time auth provider setup during OOBE.
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
* Rate-limited by the API gateway; additionally restricted to first-time setup only.
|
||||
* After setup completes, this endpoint permanently returns 403.
|
||||
*/
|
||||
setupRouter.post("/auth-provider", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
let row: typeof authProviderConfig.$inferSelect;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [superUser] = await tx
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
if (superUser) {
|
||||
throw Object.assign(new Error("setup-complete"), { code: 403 });
|
||||
}
|
||||
|
||||
const [existingConfig] = await tx
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (existingConfig) {
|
||||
throw Object.assign(new Error("config-exists"), { code: 409 });
|
||||
}
|
||||
|
||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||
|
||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||
|
||||
const [configRow] = await tx
|
||||
.insert(authProviderConfig)
|
||||
.values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!configRow) {
|
||||
throw Object.assign(new Error("insert-failed"), { code: 500 });
|
||||
}
|
||||
|
||||
return configRow;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { code?: number };
|
||||
if (e.message === "setup-complete") {
|
||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403);
|
||||
}
|
||||
if (e.message === "config-exists") {
|
||||
return c.json({ error: "Auth provider is already configured." }, e.code as 409);
|
||||
}
|
||||
if (e.message === "insert-failed") {
|
||||
return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/setup/auth-provider/test
|
||||
* Unauthenticated endpoint to validate an OIDC provider configuration during OOBE.
|
||||
* Fetches the OIDC discovery document to confirm the issuer is reachable.
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
*/
|
||||
setupRouter.post("/auth-provider/test", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
if (superUser) {
|
||||
return c.json({ ok: false, error: "Setup has already been completed." }, 403);
|
||||
}
|
||||
|
||||
const body = authProviderTestSchema.parse(await c.req.json());
|
||||
|
||||
// Determine the discovery URL
|
||||
const discoveryUrl = body.internalBaseUrl
|
||||
? `${body.internalBaseUrl.replace(/\/$/, "")}/application/o/.well-known/openid-configuration`
|
||||
: `${body.issuerUrl}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const res = await fetch(discoveryUrl, { method: "GET", signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: `OIDC discovery failed (HTTP ${res.status}). Check your Issuer URL and Internal Base URL.`,
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
} catch {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: "Could not reach the OIDC provider. Check your Issuer URL and network connectivity.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, getDb, ne, staff, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const staffRouter = new Hono<AppEnv>();
|
||||
|
||||
const createStaffSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
role: z.enum(["groomer", "receptionist", "manager"]).default("groomer"),
|
||||
oidcSub: z.string().optional(),
|
||||
active: z.boolean().default(true),
|
||||
isSuperUser: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
||||
|
||||
const linkUserSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
staffRouter.get("/me", async (c) => {
|
||||
const staffRow = c.get("staff");
|
||||
return c.json(staffRow);
|
||||
});
|
||||
|
||||
staffRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
const rows = includeInactive
|
||||
? await db.select().from(staff).orderBy(staff.name)
|
||||
: await db.select().from(staff).where(eq(staff.active, true)).orderBy(staff.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
staffRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
staffRouter.post("/", zValidator("json", createStaffSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(staff).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const currentStaff = c.get("staff");
|
||||
const targetId = c.req.param("id");
|
||||
|
||||
// Super user check: only super users can change isSuperUser
|
||||
if (body.isSuperUser !== undefined && !currentStaff.isSuperUser) {
|
||||
return c.json({ error: "Forbidden: only super users can grant or revoke super user status" }, 403);
|
||||
}
|
||||
|
||||
// If revoking super user status, check last-super-user guardrail
|
||||
if (body.isSuperUser === false) {
|
||||
const superUserCount = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.isSuperUser, true), eq(staff.active, true)))
|
||||
.limit(2); // just need count; fetch 2 to know if > 1
|
||||
if (superUserCount.length <= 1) {
|
||||
return c.json(
|
||||
{ error: "Cannot revoke the last super user. Assign another super user first." },
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If deactivating a super user, check last-super-user guardrail
|
||||
if (body.active === false) {
|
||||
const [target] = await db
|
||||
.select({ isSuperUser: staff.isSuperUser })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, targetId))
|
||||
.limit(1);
|
||||
if (target?.isSuperUser) {
|
||||
const superUserCount = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.isSuperUser, true), eq(staff.active, true)))
|
||||
.limit(2);
|
||||
if (superUserCount.length <= 1) {
|
||||
return c.json(
|
||||
{ error: "Cannot deactivate the last super user. Assign another super user first." },
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(staff)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(staff.id, targetId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const targetId = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const currentStaff = c.get("staff");
|
||||
|
||||
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
|
||||
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, targetId))
|
||||
.limit(1);
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const [updated] = await db
|
||||
.update(staff)
|
||||
.set({ userId: body.userId, updatedAt: new Date() })
|
||||
.where(eq(staff.id, targetId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
staffRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
// Prevent deleting staff who have existing non-cancelled appointments (fixes #21).
|
||||
const activeAppointments = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, id),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (activeAppointments.length > 0) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Cannot delete staff member with existing appointments. Reassign or cancel their appointments first.",
|
||||
},
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent deleting the last super user
|
||||
const [target] = await db
|
||||
.select({ isSuperUser: staff.isSuperUser })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.limit(1);
|
||||
if (target?.isSuperUser) {
|
||||
const superUserCount = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.isSuperUser, true), eq(staff.active, true)))
|
||||
.limit(2);
|
||||
if (superUserCount.length <= 1) {
|
||||
return c.json(
|
||||
{ error: "Cannot delete the last super user. Assign another super user first." },
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.delete(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
staffRouter.post("/:id/ical-token", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
if (staffRow.role !== "manager" && staffRow.id !== id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!member) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const [updated] = await db
|
||||
.update(staff)
|
||||
.set({ icalToken: token, updatedAt: new Date() })
|
||||
.where(eq(staff.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ icalToken: updated.icalToken });
|
||||
});
|
||||
|
||||
staffRouter.delete("/:id/ical-token", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
if (staffRow.role !== "manager" && staffRow.id !== id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!member) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
await db
|
||||
.update(staff)
|
||||
.set({ icalToken: null, updatedAt: new Date() })
|
||||
.where(eq(staff.id, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
webhooksRouter.post("/stripe", async (c) => {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||
}
|
||||
|
||||
const signature = c.req.header("stripe-signature");
|
||||
if (!signature) {
|
||||
return c.json({ error: "Missing signature" }, 401);
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await c.req.text();
|
||||
} catch {
|
||||
return c.json({ error: "Could not read body" }, 400);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) {
|
||||
return c.json({ error: "Stripe not configured" }, 503);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||
return c.json({ error: message }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (event.type === "payment_intent.succeeded") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
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))
|
||||
.limit(1);
|
||||
if (!inv) continue;
|
||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "paid",
|
||||
paymentMethod: "card",
|
||||
paidAt: new Date(),
|
||||
stripePaymentIntentId: pi.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.refunded") {
|
||||
const charge = event.data.object as Stripe.Charge;
|
||||
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
|
||||
const [inv] = await db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
|
||||
.limit(1);
|
||||
if (inv) {
|
||||
const refundId =
|
||||
typeof charge.refunded === "boolean" && charge.refunded
|
||||
? `ch_${charge.id}_refund`
|
||||
: null;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "void",
|
||||
stripeRefundId: refundId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, inv.id));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.dispute.created") {
|
||||
const dispute = event.data.object as Stripe.Dispute;
|
||||
console.error(
|
||||
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
lt,
|
||||
getDb,
|
||||
waitlistEntries,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const waitlistRouter = new Hono<AppEnv>();
|
||||
|
||||
async function markExpiredEntries(db: ReturnType<typeof getDb>, rows: { status: string; preferredDate: string }[]) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const hasExpired = rows.some((r) => r.status === "active" && r.preferredDate < today);
|
||||
if (hasExpired) {
|
||||
await db
|
||||
.update(waitlistEntries)
|
||||
.set({ status: "expired", updatedAt: new Date() })
|
||||
.where(and(eq(waitlistEntries.status, "active"), lt(waitlistEntries.preferredDate, today)));
|
||||
}
|
||||
}
|
||||
|
||||
waitlistRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const date = c.req.query("date");
|
||||
|
||||
const conditions = [];
|
||||
if (date) {
|
||||
conditions.push(eq(waitlistEntries.preferredDate, date));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: waitlistEntries.id,
|
||||
clientId: waitlistEntries.clientId,
|
||||
petId: waitlistEntries.petId,
|
||||
serviceId: waitlistEntries.serviceId,
|
||||
preferredDate: waitlistEntries.preferredDate,
|
||||
preferredTime: waitlistEntries.preferredTime,
|
||||
status: waitlistEntries.status,
|
||||
notifiedAt: waitlistEntries.notifiedAt,
|
||||
expiresAt: waitlistEntries.expiresAt,
|
||||
createdAt: waitlistEntries.createdAt,
|
||||
updatedAt: waitlistEntries.updatedAt,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(waitlistEntries)
|
||||
.leftJoin(clients, eq(waitlistEntries.clientId, clients.id))
|
||||
.leftJoin(pets, eq(waitlistEntries.petId, pets.id))
|
||||
.leftJoin(services, eq(waitlistEntries.serviceId, services.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(waitlistEntries.createdAt);
|
||||
|
||||
await markExpiredEntries(db, rows);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const enriched = rows.map((row) => ({
|
||||
...row,
|
||||
status: row.status === "active" && row.preferredDate < today ? "expired" : row.status,
|
||||
}));
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
waitlistRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, c.req.param("id")))
|
||||
.limit(1);
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
await markExpiredEntries(db, [row]);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const isExpired = row.status === "active" && row.preferredDate < today;
|
||||
return c.json({
|
||||
...row,
|
||||
status: isExpired ? "expired" : row.status,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user