Compare commits

...

2 Commits

Author SHA1 Message Date
Flea Flicker fb9f83d638 fix(GRO-643): update test to include required email field
The test was sending only name without email, but since email became
required in createClientSchema, it now returns 400 instead of 201.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-17 00:44:41 +00:00
Flea Flicker da16ac8ac2 Add missing DB indexes, NOT NULL on clients.email, and S3 error handling
- Add 4 indexes on appointments: client_id, staff_id, start_time, status
- Add index on pets.client_id
- Add index on clients.email
- Change clients.email to NOT NULL with backfill migration
- Wrap S3 deleteObject calls in try/catch in pets photo endpoints
- Update POST /clients test to include required email field

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 10:09:57 +00:00
3 changed files with 70 additions and 42 deletions
+2 -2
View File
@@ -195,8 +195,8 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie"); expect(insertedValues[0]!.name).toBe("Charlie");
}); });
it("creates a client with only required name field", async () => { it("creates a client with required fields", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana" }); const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana"); expect(insertedValues[0]!.name).toBe("Dana");
}); });
@@ -0,0 +1,20 @@
-- Migration: 0029_db_indexes_constraints.sql
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
-- Backfill NULL emails before setting NOT NULL
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
-- Add indexes on appointments table
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
CREATE INDEX idx_appointments_status ON appointments(status);
-- Add index on pets table
CREATE INDEX idx_pets_client_id ON pets(client_id);
-- Add index on clients table
CREATE INDEX idx_clients_email ON clients(email);
-- Set NOT NULL on clients.email (after backfill)
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
+48 -40
View File
@@ -102,47 +102,55 @@ export const verification = pgTable("verification", {
// ─── Tables ─────────────────────────────────────────────────────────────────── // ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable("clients", { export const clients = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "clients",
name: text("name").notNull(), {
email: text("email"), id: uuid("id").primaryKey().defaultRandom(),
phone: text("phone"), name: text("name").notNull(),
address: text("address"), email: text("email").notNull(),
notes: text("notes"), phone: text("phone"),
emailOptOut: boolean("email_opt_out").notNull().default(false), address: text("address"),
smsOptIn: boolean("sms_opt_in").notNull().default(false), notes: text("notes"),
smsConsentDate: timestamp("sms_consent_date"), emailOptOut: boolean("email_opt_out").notNull().default(false),
smsOptOutDate: timestamp("sms_opt_out_date"), smsOptIn: boolean("sms_opt_in").notNull().default(false),
smsConsentText: text("sms_consent_text"), smsConsentDate: timestamp("sms_consent_date"),
stripeCustomerId: text("stripe_customer_id"), smsOptOutDate: timestamp("sms_opt_out_date"),
status: clientStatusEnum("status").notNull().default("active"), smsConsentText: text("sms_consent_text"),
disabledAt: timestamp("disabled_at"), stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at").notNull().defaultNow(), status: clientStatusEnum("status").notNull().default("active"),
updatedAt: timestamp("updated_at").notNull().defaultNow(), disabledAt: timestamp("disabled_at"),
}); createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_clients_email").on(t.email)]
);
export const pets = pgTable("pets", { export const pets = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "pets",
clientId: uuid("client_id") {
.notNull() id: uuid("id").primaryKey().defaultRandom(),
.references(() => clients.id, { onDelete: "cascade" }), clientId: uuid("client_id")
name: text("name").notNull(), .notNull()
species: text("species").notNull(), .references(() => clients.id, { onDelete: "cascade" }),
breed: text("breed"), name: text("name").notNull(),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }), species: text("species").notNull(),
dateOfBirth: timestamp("date_of_birth"), breed: text("breed"),
healthAlerts: text("health_alerts"), weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
groomingNotes: text("grooming_notes"), dateOfBirth: timestamp("date_of_birth"),
cutStyle: text("cut_style"), healthAlerts: text("health_alerts"),
shampooPreference: text("shampoo_preference"), groomingNotes: text("grooming_notes"),
specialCareNotes: text("special_care_notes"), cutStyle: text("cut_style"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}), shampooPreference: text("shampoo_preference"),
photoKey: text("photo_key"), specialCareNotes: text("special_care_notes"),
photoUploadedAt: timestamp("photo_uploaded_at"), customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
image: text("image"), photoKey: text("photo_key"),
createdAt: timestamp("created_at").notNull().defaultNow(), photoUploadedAt: timestamp("photo_uploaded_at"),
updatedAt: timestamp("updated_at").notNull().defaultNow(), image: text("image"),
}); createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_pets_client_id").on(t.clientId)]
);
export const services = pgTable("services", { export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),