From 156e1fa4ff383ef96c55b0a2339c5fe6267de61d Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 05:23:40 +0000 Subject: [PATCH] fix(GRO-643): add appointment indexes to schema and S3 error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add idx_appointments_client_id, idx_appointments_staff_id, idx_appointments_start_time, idx_appointments_status to schema. Migration 0029 already handles the DB side; this brings schema.ts in sync so drizzle-kit push is clean going forward. - Wrap deleteObject calls in try/catch (POST /photo/confirm and DELETE /:petId/photo endpoints) so S3 failures don't abort the DB update — orphaned objects are logged as warnings instead. Co-Authored-By: Paperclip --- apps/api/src/routes/pets.ts | 12 ++++- packages/db/src/schema.ts | 99 ++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 47 deletions(-) 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/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",