diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index a6b9982..2264e6c 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -213,7 +213,11 @@ petsRouter.post( // Delete the previous photo from storage to avoid orphaned objects if (pet.photoKey) { - await deleteObject(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 @@ -240,7 +244,11 @@ petsRouter.delete("/:petId/photo", async (c) => { if (!pet) return c.json({ error: "Pet not found" }, 404); if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404); - await deleteObject(pet.photoKey); + 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() }) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index d768bc8..dc556c8 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -9,6 +9,68 @@ import type { PortalEnv } from "../middleware/portalSession.js"; export const portalRouter = new Hono(); +// 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); @@ -460,73 +522,4 @@ portalRouter.delete("/payment-methods/:id", async (c) => { const ok = await detachPaymentMethod(paymentMethodId); if (!ok) return c.json({ error: "Failed to detach payment method" }, 500); return c.json({ ok: true }); -}); - -// ─── Dev-mode session creation ────────────────────────────────────────────── -// Allows the dev login selector to vend an impersonation session for a client -// without requiring manager auth. Only available when AUTH_DISABLED=true. - -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"); - - // Verify client exists - 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); - } - - // Find a staff record to associate with the dev impersonation session. - // Use the demo-manager if it exists (created by seed with known ID), - // otherwise fall back to the first active staff record. - // This avoids hardcoding a UUID that may not exist in all environments. - const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001"; - - let staffId = DEMO_STAFF_ID; - const [demoStaff] = await db - .select({ id: staff.id }) - .from(staff) - .where(eq(staff.id, DEMO_STAFF_ID)) - .limit(1); - - if (!demoStaff) { - // Fall back to any active staff member - 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), // 24 hours - }) - .returning(); - - return c.json(session, 201); - } -); \ No newline at end of file +}); \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0ef3ca6..0a5eaef 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -200,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", { updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -export const appointments = pgTable("appointments", { - id: uuid("id").primaryKey().defaultRandom(), - clientId: uuid("client_id") - .notNull() - .references(() => clients.id, { onDelete: "restrict" }), - petId: uuid("pet_id") - .notNull() - .references(() => pets.id, { onDelete: "restrict" }), - serviceId: uuid("service_id") - .notNull() - .references(() => services.id, { onDelete: "restrict" }), - staffId: uuid("staff_id").references(() => staff.id, { - onDelete: "set null", - }), - // Optional secondary staff (bather/assistant) for tip-split tracking - batherStaffId: uuid("bather_staff_id").references(() => staff.id, { - onDelete: "set null", - }), - status: appointmentStatusEnum("status").notNull().default("scheduled"), - startTime: timestamp("start_time").notNull(), - endTime: timestamp("end_time").notNull(), - notes: text("notes"), - // Override price at time of booking (null = use service base price) - priceCents: integer("price_cents"), - // Recurring series support - seriesId: uuid("series_id").references(() => recurringSeries.id, { - onDelete: "set null", - }), - seriesIndex: integer("series_index"), - // Multi-pet group booking: links this appointment to others in the same visit - groupId: uuid("group_id").references(() => appointmentGroups.id, { - onDelete: "set null", - }), - // Customer confirmation/cancellation tracking - // Values: "pending" | "confirmed" | "cancelled" - confirmationStatus: text("confirmation_status").notNull().default("pending"), - confirmedAt: timestamp("confirmed_at"), - 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(), -}); +export const appointments = pgTable( + "appointments", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + petId: uuid("pet_id") + .notNull() + .references(() => pets.id, { onDelete: "restrict" }), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "restrict" }), + staffId: uuid("staff_id").references(() => staff.id, { + onDelete: "set null", + }), + // Optional secondary staff (bather/assistant) for tip-split tracking + batherStaffId: uuid("bather_staff_id").references(() => staff.id, { + onDelete: "set null", + }), + status: appointmentStatusEnum("status").notNull().default("scheduled"), + startTime: timestamp("start_time").notNull(), + endTime: timestamp("end_time").notNull(), + notes: text("notes"), + // Override price at time of booking (null = use service base price) + priceCents: integer("price_cents"), + // Recurring series support + seriesId: uuid("series_id").references(() => recurringSeries.id, { + onDelete: "set null", + }), + seriesIndex: integer("series_index"), + // Multi-pet group booking: links this appointment to others in the same visit + groupId: uuid("group_id").references(() => appointmentGroups.id, { + onDelete: "set null", + }), + // Customer confirmation/cancellation tracking + // Values: "pending" | "confirmed" | "cancelled" + confirmationStatus: text("confirmation_status").notNull().default("pending"), + confirmedAt: timestamp("confirmed_at"), + 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(), + }, + (t) => [ + index("idx_appointments_client_id").on(t.clientId), + index("idx_appointments_staff_id").on(t.staffId), + index("idx_appointments_start_time").on(t.startTime), + index("idx_appointments_status").on(t.status), + ] +); export const invoices = pgTable( "invoices",