9eb0c3d151
* Implement confirm/cancel in customer portal (GRO-50) Backend: - Add POST /api/portal/appointments/:id/confirm endpoint - Validates impersonation session auth and ownership - Rejects past/in-progress, non-pending, or already-cancelled/completed - Sets confirmationStatus="confirmed", confirmedAt, updatedAt - Add POST /api/portal/appointments/:id/cancel endpoint - Same auth/ownership pattern - Rejects past/in-progress or already-cancelled/completed - Sets status="cancelled", confirmationStatus="cancelled", cancelledAt, updatedAt Frontend (Appointments.tsx): - Add confirmationStatus field to Appointment type and mock data - Add ConfirmationSection component: shows status badge + confirm button - Add CancelAppointmentButton: wires to cancel API with loading/error state - Wire existing Cancel button to CancelAppointmentButton - Show confirmation status badge in expanded view for upcoming appointments Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(gro-48): row-level data scoping for groomer role (RBAC Phase 2) Filter query results at the route handler level when staff role is groomer: - GET /api/appointments: WHERE staffId = groomer OR batherStaffId = groomer - GET /api/appointments/🆔 403 if not assigned to groomer (as staff or bather) - GET /api/clients: Clients with ≥1 appointment for this groomer (via exists subquery) - GET /api/clients/🆔 403 if no appointment linkage - GET /api/pets: Pets owned by groomer-linked clients (via exists subquery) - GET /api/pets/:petId: 403 if no appointment linkage Managers and receptionists: no change. Added exists to @groombook/db exports (was missing from re-export). Added groomerIsolation unit tests for role guard and filter logic. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-50): add portal confirm/cancel tests and fix ConfirmationSection state - Add test coverage for POST /portal/appointments/:id/confirm endpoint - Add test coverage for POST /portal/appointments/:id/cancel endpoint - Fix ConfirmationSection not updating local status after successful confirm - Remove unused onCancel prop from ConfirmationSection call site - Fix Appointments.test.tsx missing confirmationStatus field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(gro-50): add ConfirmationSection UI component tests Add tests for the ConfirmationSection component: - Renders correct badge for each confirmationStatus state - Shows/hides Confirm button based on status - Calls confirm API with correct headers - Handles sessionId null case - Shows error messages for 401/403/422 responses - Shows loading state while confirming - Shows success message briefly after confirm - Does not call API if user cancels confirm dialog Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-48): address QA review feedback — staffRow?.role and portal TS guards - appointments.ts: use staffRow?.role (consistent with clients.ts/pets.ts) to handle undefined staff context safely - portal.ts: add null guards on .returning() results for confirm and cancel endpoints (TS18048: 'updated' is possibly undefined) - All 188 tests passing; TypeScript typecheck clean Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro66): use specific selector for banner visibility assertion Replace ambiguous `getByText("STAFF VIEW")` that matched both the ImpersonationBanner and the CustomerPortal watermark with a precise `getByTestId("impersonation-banner")` selector to eliminate strict mode violations. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-66): add missing afterEach to vitest import Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-48): add icalToken to MANAGER mock after rebase After rebasing onto origin/main (which added icalToken to the staff schema via GRO-107), the MANAGER mock in groomerIsolation.test.ts was missing the new required field. Added icalToken: null to the MANAGER constant. factories.ts is clean (no duplicate icalToken after rebase). Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-47): add non-null assertions on Drizzle RETURNING results Drizzle's update().returning() types the array element as T | undefined. After the if (!appt) guard, updated is still typed as possibly undefined because RETURNING can succeed with no rows. Add ! assertions since we already guard with the existence check. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Flea Flicker <fleaflicker@groombook.ai> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing>
370 lines
9.6 KiB
TypeScript
370 lines
9.6 KiB
TypeScript
import { Hono } from "hono";
|
|
import { zValidator } from "@hono/zod-validator";
|
|
import { z } from "zod";
|
|
import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
|
|
import type { AppEnv } from "../middleware/rbac.js";
|
|
|
|
export const portalRouter = new Hono<AppEnv>();
|
|
|
|
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 sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
if (!sessionId) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [session] = await db
|
|
.select()
|
|
.from(impersonationSessions)
|
|
.where(
|
|
and(
|
|
eq(impersonationSessions.id, sessionId),
|
|
eq(impersonationSessions.status, "active")
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (!session || session.expiresAt <= new Date()) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const authClientId = session.clientId;
|
|
|
|
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 !== authClientId) {
|
|
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 sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
if (!sessionId) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [session] = await db
|
|
.select()
|
|
.from(impersonationSessions)
|
|
.where(
|
|
and(
|
|
eq(impersonationSessions.id, sessionId),
|
|
eq(impersonationSessions.status, "active")
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (!session || session.expiresAt <= new Date()) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [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 !== session.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 sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
if (!sessionId) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [session] = await db
|
|
.select()
|
|
.from(impersonationSessions)
|
|
.where(
|
|
and(
|
|
eq(impersonationSessions.id, sessionId),
|
|
eq(impersonationSessions.status, "active")
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (!session || session.expiresAt <= new Date()) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [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 !== session.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 sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
|
|
let clientId: string | null = null;
|
|
if (sessionId) {
|
|
const [session] = await db
|
|
.select()
|
|
.from(impersonationSessions)
|
|
.where(
|
|
and(
|
|
eq(impersonationSessions.id, sessionId),
|
|
eq(impersonationSessions.status, "active")
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (session && session.expiresAt > new Date()) {
|
|
clientId = session.clientId;
|
|
}
|
|
}
|
|
|
|
if (!clientId) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [entry] = await db
|
|
.insert(waitlistEntries)
|
|
.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 sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
|
|
if (!sessionId) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [session] = await db
|
|
.select()
|
|
.from(impersonationSessions)
|
|
.where(
|
|
and(
|
|
eq(impersonationSessions.id, sessionId),
|
|
eq(impersonationSessions.status, "active")
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (!session || session.expiresAt <= new Date()) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [existing] = await db
|
|
.select()
|
|
.from(waitlistEntries)
|
|
.where(eq(waitlistEntries.id, id))
|
|
.limit(1);
|
|
|
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
|
if (existing.clientId !== session.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 sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
|
|
if (!sessionId) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [session] = await db
|
|
.select()
|
|
.from(impersonationSessions)
|
|
.where(
|
|
and(
|
|
eq(impersonationSessions.id, sessionId),
|
|
eq(impersonationSessions.status, "active")
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (!session || session.expiresAt <= new Date()) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const [entry] = await db
|
|
.select()
|
|
.from(waitlistEntries)
|
|
.where(eq(waitlistEntries.id, id))
|
|
.limit(1);
|
|
|
|
if (!entry) return c.json({ error: "Not found" }, 404);
|
|
if (entry.clientId !== session.clientId) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
await db
|
|
.delete(waitlistEntries)
|
|
.where(eq(waitlistEntries.id, id))
|
|
.returning();
|
|
|
|
return c.json({ ok: true });
|
|
});
|