Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 08e15dafd5 fix(GRO-639): replace N+1 per-appointment queries with single JOIN query
Replace the per-appointment N+1 queries (client, pet, service, staff)
with a single JOIN query that fetches all appointment data at once.
Batch-fetch already-sent reminder IDs in a single query too.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 04:37:40 +00:00
9 changed files with 167 additions and 320 deletions
-90
View File
@@ -1,90 +0,0 @@
# Contributing to GroomBook
## Branch Strategy
GroomBook uses a three-branch GitOps model:
| Branch | Environment | Purpose |
|--------|-------------|---------|
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
## Developer Workflow
1. **Branch from `dev`** — create a feature or fix branch:
```bash
git checkout dev
git pull origin dev
git checkout -b feat/my-feature
```
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
```bash
gh pr create --base dev --title "feat: description (GRO-NNN)" \
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
```
3. **Pipeline gates before merge to `dev`:**
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
- CTO (The Dogfather) reviews second — architecture and final approval
- Both must approve; 2 approving reviews required by branch protection
## Promotion Flow
### Dev → UAT
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
```bash
gh pr create --base uat --head dev \
--title "chore: promote dev to uat (YYYY.MM.DD)" \
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
```
Gates:
- Shedward Scissorhands runs regression/acceptance tests
- Barkley Trimsworth performs security review
- CTO approves and merges (1 approving review required)
### UAT → Main (Production)
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
```bash
gh pr create --base main --head uat \
--title "chore: promote uat to main (YYYY.MM.DD)" \
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
```
Gates:
- CEO (Scrubs McBarkley) reviews for business alignment and merges
- 1 approving review required; triggers auto-deploy to Production
## Branch Protection Summary
| Branch | Required Approvals | Who approves |
|--------|--------------------|-------------|
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
| `uat` | 1 | CTO (The Dogfather) |
| `main` | 1 | CEO (Scrubs McBarkley) |
Force-pushes and branch deletions are disabled on all three branches.
## Commit Style
Use [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — new feature
- `fix:` — bug fix
- `chore:` — maintenance (dependency updates, build config, promotions)
- `docs:` — documentation only
- `ci:` — CI/CD changes
- `refactor:` — code restructure without behaviour change
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
## Questions?
Open a Paperclip issue in the GRO project or ask in the team channel.
+2 -3
View File
@@ -195,11 +195,10 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie"); expect(insertedValues[0]!.name).toBe("Charlie");
}); });
it("creates a client with name and email", async () => { it("creates a client with only required name field", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" }); const res = await jsonRequest("POST", "/clients", { name: "Dana" });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana"); expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
}); });
it("rejects empty name", async () => { it("rejects empty name", async () => {
+8 -4
View File
@@ -204,11 +204,15 @@ export async function initAuth(): Promise<void> {
const userInfoUrl = discovery.userinfo_endpoint; const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) { if (authzUrl && tokenUrl && userInfoUrl) {
const authzUrlObj = new URL(authzUrl); const authzUrlObj = new URL(authzUrl);
// Only validate authorizationUrl hostname against issuer — token/userinfo const tokenUrlObj = new URL(tokenUrl);
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls. const userInfoUrlObj = new URL(userInfoUrl);
if (authzUrlObj.hostname !== issuerHostname) { if (
authzUrlObj.hostname !== issuerHostname ||
tokenUrlObj.hostname !== issuerHostname ||
userInfoUrlObj.hostname !== issuerHostname
) {
throw new Error( throw new Error(
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.` `[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}', '${tokenUrlObj.hostname}', or '${userInfoUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
); );
} }
oidcConfig = { oidcConfig = {
+33 -24
View File
@@ -338,35 +338,44 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect appt: typeof appointments.$inferSelect
): Promise<void> { ): Promise<void> {
const [row] = await db const [client] = await db
.select({ .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
clientName: clients.name, .from(clients)
clientEmail: clients.email, .where(eq(clients.id, appt.clientId))
clientEmailOptOut: clients.emailOptOut,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, appt.id))
.limit(1); .limit(1);
if (!row) return; if (!client || !client.email || client.emailOptOut) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!clientEmail || clientEmailOptOut) return; const [pet] = await db
if (!petName || !serviceName) return; .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) return;
const sent = await sendEmail( const sent = await sendEmail(
buildConfirmationEmail(clientEmail, { buildConfirmationEmail(client.email, {
clientName, clientName: client.name,
petName, petName: pet.name,
serviceName, serviceName: service.name,
groomerName: groomerName ?? null, groomerName,
startTime: appt.startTime, startTime: appt.startTime,
}) })
); );
+1 -1
View File
@@ -8,7 +8,7 @@ export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({ const createClientSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
email: z.string().email(), email: z.string().email().optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
+83 -74
View File
@@ -6,6 +6,7 @@ import {
getDb, getDb,
gte, gte,
lt, lt,
sql,
appointments, appointments,
clients, clients,
pets, pets,
@@ -31,6 +32,8 @@ 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();
@@ -59,69 +62,74 @@ 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) {
const [emailLog] = await db // Already sent a reminder for this appointment in this window
.select({ id: reminderLogs.id }) if (sentAppointmentIds.has(appt.id)) continue;
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
)
)
.limit(1);
const [smsLog] = await db const row = appointmentMap.get(appt.id);
.select({ id: reminderLogs.id }) if (!row) continue;
.from(reminderLogs) if (!row.petName || !row.serviceName) continue;
.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");
@@ -131,22 +139,22 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
if (!emailLog) { const clientName = row.clientName;
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(
client.email, row.clientEmail,
{ { 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)
@@ -155,20 +163,21 @@ export async function runReminderCheck(): Promise<void> {
} }
} }
if (!smsLog && client.smsOptIn && client.phone) { // SMS reminder
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 ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`, `Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`, `Service: ${serviceName}${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(client.phone, smsBody); const smsOk = await smsSend(row.clientPhone, smsBody);
if (smsOk) { if (smsOk) {
await db await db
.insert(reminderLogs) .insert(reminderLogs)
@@ -1,20 +0,0 @@
-- 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;
+40 -48
View File
@@ -102,55 +102,47 @@ export const verification = pgTable("verification", {
// ─── Tables ─────────────────────────────────────────────────────────────────── // ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable( export const clients = pgTable("clients", {
"clients", id: uuid("id").primaryKey().defaultRandom(),
{ name: text("name").notNull(),
id: uuid("id").primaryKey().defaultRandom(), email: text("email"),
name: text("name").notNull(), phone: text("phone"),
email: text("email").notNull(), address: text("address"),
phone: text("phone"), notes: text("notes"),
address: text("address"), emailOptOut: boolean("email_opt_out").notNull().default(false),
notes: text("notes"), smsOptIn: boolean("sms_opt_in").notNull().default(false),
emailOptOut: boolean("email_opt_out").notNull().default(false), smsConsentDate: timestamp("sms_consent_date"),
smsOptIn: boolean("sms_opt_in").notNull().default(false), smsOptOutDate: timestamp("sms_opt_out_date"),
smsConsentDate: timestamp("sms_consent_date"), smsConsentText: text("sms_consent_text"),
smsOptOutDate: timestamp("sms_opt_out_date"), stripeCustomerId: text("stripe_customer_id"),
smsConsentText: text("sms_consent_text"), status: clientStatusEnum("status").notNull().default("active"),
stripeCustomerId: text("stripe_customer_id"), disabledAt: timestamp("disabled_at"),
status: clientStatusEnum("status").notNull().default("active"), createdAt: timestamp("created_at").notNull().defaultNow(),
disabledAt: timestamp("disabled_at"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(), });
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_clients_email").on(t.email)]
);
export const pets = pgTable( export const pets = pgTable("pets", {
"pets", id: uuid("id").primaryKey().defaultRandom(),
{ clientId: uuid("client_id")
id: uuid("id").primaryKey().defaultRandom(), .notNull()
clientId: uuid("client_id") .references(() => clients.id, { onDelete: "cascade" }),
.notNull() name: text("name").notNull(),
.references(() => clients.id, { onDelete: "cascade" }), species: text("species").notNull(),
name: text("name").notNull(), breed: text("breed"),
species: text("species").notNull(), weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
breed: text("breed"), dateOfBirth: timestamp("date_of_birth"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }), healthAlerts: text("health_alerts"),
dateOfBirth: timestamp("date_of_birth"), groomingNotes: text("grooming_notes"),
healthAlerts: text("health_alerts"), cutStyle: text("cut_style"),
groomingNotes: text("grooming_notes"), shampooPreference: text("shampoo_preference"),
cutStyle: text("cut_style"), specialCareNotes: text("special_care_notes"),
shampooPreference: text("shampoo_preference"), customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
specialCareNotes: text("special_care_notes"), photoKey: text("photo_key"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}), photoUploadedAt: timestamp("photo_uploaded_at"),
photoKey: text("photo_key"), image: text("image"),
photoUploadedAt: timestamp("photo_uploaded_at"), createdAt: timestamp("created_at").notNull().defaultNow(),
image: text("image"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
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(),
-56
View File
@@ -462,37 +462,6 @@ async function seedKnownUsers() {
} }
} }
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
const [existingGroomer] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, email))
.limit(1);
if (existingGroomer) {
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff groomer '${name}' (${email})`);
}
}
// ── Services: idempotent upsert using name as unique key ───────────────────── // ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first. // UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services. // Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -660,31 +629,6 @@ async function seed() {
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
} }
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
await db.insert(schema.staff)
.values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
})
.onConflictDoUpdate({
target: schema.staff.email,
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
});
console.log(`✓ Upserted groomer '${name}' (${email})`);
}
// ── Services ── // ── Services ──
// Upsert services using name as unique key. With deterministic IDs in // Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is // servicesDef and TRUNCATE clearing downstream tables first, this is