fix(GRO-643): add appointment indexes to schema and S3 error handling (#315)

- 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: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #315.
This commit is contained in:
lint-roller-qa[bot]
2026-04-17 06:42:01 +00:00
committed by GitHub
parent 2577e33c50
commit 772f4df62f
2 changed files with 64 additions and 47 deletions
+10 -2
View File
@@ -213,7 +213,11 @@ petsRouter.post(
// Delete the previous photo from storage to avoid orphaned objects // Delete the previous photo from storage to avoid orphaned objects
if (pet.photoKey) { 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 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) return c.json({ error: "Pet not found" }, 404);
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 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 await db
.update(pets) .update(pets)
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() }) .set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
+54 -45
View File
@@ -200,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", {
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });
export const appointments = pgTable("appointments", { export const appointments = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "appointments",
clientId: uuid("client_id") {
.notNull() id: uuid("id").primaryKey().defaultRandom(),
.references(() => clients.id, { onDelete: "restrict" }), clientId: uuid("client_id")
petId: uuid("pet_id") .notNull()
.notNull() .references(() => clients.id, { onDelete: "restrict" }),
.references(() => pets.id, { onDelete: "restrict" }), petId: uuid("pet_id")
serviceId: uuid("service_id") .notNull()
.notNull() .references(() => pets.id, { onDelete: "restrict" }),
.references(() => services.id, { onDelete: "restrict" }), serviceId: uuid("service_id")
staffId: uuid("staff_id").references(() => staff.id, { .notNull()
onDelete: "set null", .references(() => services.id, { onDelete: "restrict" }),
}), staffId: uuid("staff_id").references(() => staff.id, {
// Optional secondary staff (bather/assistant) for tip-split tracking onDelete: "set null",
batherStaffId: uuid("bather_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, {
status: appointmentStatusEnum("status").notNull().default("scheduled"), onDelete: "set null",
startTime: timestamp("start_time").notNull(), }),
endTime: timestamp("end_time").notNull(), status: appointmentStatusEnum("status").notNull().default("scheduled"),
notes: text("notes"), startTime: timestamp("start_time").notNull(),
// Override price at time of booking (null = use service base price) endTime: timestamp("end_time").notNull(),
priceCents: integer("price_cents"), notes: text("notes"),
// Recurring series support // Override price at time of booking (null = use service base price)
seriesId: uuid("series_id").references(() => recurringSeries.id, { priceCents: integer("price_cents"),
onDelete: "set null", // Recurring series support
}), seriesId: uuid("series_id").references(() => recurringSeries.id, {
seriesIndex: integer("series_index"), onDelete: "set null",
// Multi-pet group booking: links this appointment to others in the same visit }),
groupId: uuid("group_id").references(() => appointmentGroups.id, { seriesIndex: integer("series_index"),
onDelete: "set null", // Multi-pet group booking: links this appointment to others in the same visit
}), groupId: uuid("group_id").references(() => appointmentGroups.id, {
// Customer confirmation/cancellation tracking onDelete: "set null",
// Values: "pending" | "confirmed" | "cancelled" }),
confirmationStatus: text("confirmation_status").notNull().default("pending"), // Customer confirmation/cancellation tracking
confirmedAt: timestamp("confirmed_at"), // Values: "pending" | "confirmed" | "cancelled"
cancelledAt: timestamp("cancelled_at"), confirmationStatus: text("confirmation_status").notNull().default("pending"),
// Token for tokenized email confirm/cancel links (no auth required) confirmedAt: timestamp("confirmed_at"),
confirmationToken: text("confirmation_token").unique(), cancelledAt: timestamp("cancelled_at"),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts) // Token for tokenized email confirm/cancel links (no auth required)
customerNotes: text("customer_notes"), confirmationToken: text("confirmation_token").unique(),
createdAt: timestamp("created_at").notNull().defaultNow(), // Customer-provided note visible to groomer (500 char max, editable until appointment starts)
updatedAt: timestamp("updated_at").notNull().defaultNow(), 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( export const invoices = pgTable(
"invoices", "invoices",