diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 79a6cd6..c940e0d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; import { appointmentsRouter } from "./routes/appointments.js"; +import { portalRouter } from "./routes/portal.js"; import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; import { bookRouter } from "./routes/book.js"; @@ -107,6 +108,7 @@ api.route("/clients", clientsRouter); api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); +api.route("/portal", portalRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts new file mode 100644 index 0000000..792d62a --- /dev/null +++ b/apps/api/src/routes/portal.ts @@ -0,0 +1,69 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { and, eq, getDb, appointments, impersonationSessions } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const portalRouter = new Hono(); + +const customerNotesSchema = z.object({ + customerNotes: z.string().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(); + + return c.json(updated); + } +); diff --git a/packages/db/migrations/0014_customer_notes.sql b/packages/db/migrations/0014_customer_notes.sql new file mode 100644 index 0000000..9599808 --- /dev/null +++ b/packages/db/migrations/0014_customer_notes.sql @@ -0,0 +1,3 @@ +ALTER TABLE appointments ADD COLUMN customer_notes TEXT; + +CREATE INDEX idx_appointments_customer_notes ON appointments (client_id) WHERE customer_notes IS NOT NULL; diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index 5cc6698..7e4d735 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -140,6 +140,7 @@ export function buildAppointment( confirmedAt: null, cancelledAt: null, confirmationToken: null, + customerNotes: null, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), }; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 35c2111..b719b92 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -169,6 +169,8 @@ export const appointments = pgTable("appointments", { cancelledAt: timestamp("cancelled_at"), // Token for tokenized email confirm/cancel links (no auth required) confirmationToken: text("confirmation_token").unique(), + // Customer-provided note visible to groomer (500 char max, editable until appointment starts) + customerNotes: text("customer_notes"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), });