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
5 changed files with 153 additions and 137 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");
}); });
+74 -83
View File
@@ -6,7 +6,6 @@ import {
getDb, getDb,
gte, gte,
lt, lt,
sql,
appointments, appointments,
clients, clients,
pets, pets,
@@ -32,8 +31,6 @@ function getReminderWindows(): { label: string; hours: number }[] {
]; ];
} }
// Checks for upcoming appointments that need reminders and sends them.
// Runs every minute — idempotent via reminder_logs unique constraint.
export async function runReminderCheck(): Promise<void> { export async function runReminderCheck(): Promise<void> {
const db = getDb(); const db = getDb();
const now = new Date(); const now = new Date();
@@ -62,74 +59,69 @@ export async function runReminderCheck(): Promise<void> {
) )
); );
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
if (appointmentIds.length === 0) continue;
// Batch-fetch already-sent appointment IDs (both EMAIL and SMS channels)
const sentAppointmentIds = new Set(
(
await db
.select({ appointmentId: reminderLogs.appointmentId })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.reminderType, window.label),
appointmentIds.length === 1
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
: sql`${reminderLogs.appointmentId} = ANY(${appointmentIds})`
)
)
).map((r) => r.appointmentId)
);
// Batch-fetch all appointment data with related joins in a single query
const joinedRows = await db
.select({
appointmentId: appointments.id,
startTime: appointments.startTime,
clientId: appointments.clientId,
petId: appointments.petId,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
confirmationToken: appointments.confirmationToken,
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
clientPhone: clients.phone,
clientSmsOptIn: clients.smsOptIn,
petName: pets.name,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(appointments.clientId, clients.id))
.innerJoin(pets, eq(appointments.petId, pets.id))
.innerJoin(services, eq(appointments.serviceId, services.id))
.leftJoin(staff, eq(appointments.staffId, staff.id))
.where(
and(
sql`${appointments.id} = ANY(${appointmentIds})`,
gte(appointments.startTime, windowStart),
lt(appointments.startTime, windowEnd),
eq(appointments.status, "scheduled")
)
);
const appointmentMap = new Map<string, typeof joinedRows[number]>();
for (const row of joinedRows) {
appointmentMap.set(row.appointmentId, row);
}
for (const appt of upcoming) { for (const appt of upcoming) {
// Already sent a reminder for this appointment in this window const [emailLog] = await db
if (sentAppointmentIds.has(appt.id)) continue; .select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
)
)
.limit(1);
const row = appointmentMap.get(appt.id); const [smsLog] = await db
if (!row) continue; .select({ id: reminderLogs.id })
if (!row.petName || !row.serviceName) continue; .from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
const [client] = await db
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
if (!client || !client.email || client.emailOptOut) continue;
const [pet] = await db
.select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
let groomerName: string | null = null;
if (appt.staffId) {
const [groomer] = await db
.select({ name: staff.name })
.from(staff)
.where(eq(staff.id, appt.staffId))
.limit(1);
groomerName = groomer?.name ?? null;
}
if (!pet || !service) continue;
// Generate confirmation token if missing
let confirmationToken = appt.confirmationToken; let confirmationToken = appt.confirmationToken;
if (!confirmationToken) { if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex"); confirmationToken = randomBytes(32).toString("hex");
@@ -139,22 +131,22 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
const clientName = row.clientName; if (!emailLog) {
const petName = row.petName;
const serviceName = row.serviceName;
const groomerName = row.staffName ?? null;
const startTime = appt.startTime;
// EMAIL reminder
if (row.clientEmail && !row.clientEmailOptOut) {
const sent = await sendEmail( const sent = await sendEmail(
buildReminderEmail( buildReminderEmail(
row.clientEmail, client.email,
{ clientName, petName, serviceName, groomerName, startTime }, {
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours, window.hours,
confirmationToken confirmationToken
) )
); );
if (sent) { if (sent) {
await db await db
.insert(reminderLogs) .insert(reminderLogs)
@@ -163,21 +155,20 @@ export async function runReminderCheck(): Promise<void> {
} }
} }
// SMS reminder if (!smsLog && client.smsOptIn && client.phone) {
if (row.clientPhone && row.clientSmsOptIn) {
const apiUrl = process.env.API_URL ?? "http://localhost:3000"; const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
const smsBody = [ const smsBody = [
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`, `Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
`Service: ${serviceName}${groomerName ? ` with ${groomerName}` : ""}`, `Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
`Confirm: ${confirmUrl}`, `Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`, `Cancel: ${cancelUrl}`,
TCPA_OPT_OUT, TCPA_OPT_OUT,
].join(". "); ].join(". ");
try { try {
const smsOk = await smsSend(row.clientPhone, smsBody); const smsOk = await smsSend(client.phone, smsBody);
if (smsOk) { if (smsOk) {
await db await db
.insert(reminderLogs) .insert(reminderLogs)
+9 -12
View File
@@ -1,15 +1,12 @@
-- SMS opt-in fields for clients (idempotent) -- SMS opt-in fields for clients
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false; ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean NOT NULL DEFAULT false;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp; ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp; ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text; ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;
-- Add channel column to reminder_logs with default 'email' (idempotent) -- Add channel column to reminder_logs with default 'email'
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email'; ALTER TABLE "reminder_logs" ADD COLUMN "channel" text NOT NULL DEFAULT 'email';
-- Drop old unique constraints if they exist (idempotent) -- Drop the old unique constraint and recreate with channel
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key"; ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique";
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
-- Add new unique constraint with channel
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel"); ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
@@ -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(),