Compare commits

...

3 Commits

Author SHA1 Message Date
Flea Flicker 77a6ad5135 fix(pets): add extended fields to createPetSchema/updatePetSchema (GRO-2172)
createPetSchema in src/routes/pets.ts was missing temperamentScore,
temperamentFlags, medicalAlerts, and preferredCuts. Migrations 0034/0036
and seed data populate them, but POST/PATCH silently dropped the fields
because Zod validation rejected them. GET worked because seed bypassed
the schema. This is the regression originally reported as GRO-1472 and
blocks the GRO-1178 extended-profile feature rollout (api/#39).

Mirrors the field shape that already lives in apps/api/src/routes/pets.ts
(legacy duplicate) and the test expectations in
apps/api/src/__tests__/petsExtendedFields.test.ts:

- temperamentScore: int 1–5
- temperamentFlags: string[] (max 20, item max 100)
- medicalAlerts: { type, description, severity: low|medium|high }[] (max 50)
- preferredCuts: string[] (max 20, item max 200)

updatePetSchema inherits from createPetSchema.partial().omit({ clientId })
so no separate change is needed there.

POST/PATCH handlers now forward medicalAlerts through to the insert/update
with a localized cast — the @groombook/types MedicalAlert includes a
server-generated id that is not part of the API request shape, and the
jsonb column is schemaless at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:17:46 +00:00
Flea Flicker fc6c6ef752 fix(db): make services seed idempotent across resets (GRO-2064, GRO-2033 close-out) (#148)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 28s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Successful in 39s
2026-06-02 04:54:33 +00:00
The Dogfather 1a6a54cc84 security(audit): log owner-bypass reads in GET /pets/:id/profile-summary (GRO-2062) (#146)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 40s
CI / Build & Push Docker Images (pull_request) Successful in 27s
QA-approved (gb_lint) + CTO-approved. Defense-in-depth audit row on staff owner-bypass. GRO-2063.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 04:20:23 +00:00
4 changed files with 107 additions and 26 deletions
+27
View File
@@ -192,6 +192,33 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
| TC-API-5.4 | Update service | PATCH /api/services/{id} with updated fields | 200 OK, service updated |
| TC-API-5.5 | Delete service | DELETE /api/services/{id} | 200 OK, service deleted |
#### 4.5.1 Seed/Reset idempotency (GRO-2064)
Services seeding is now keyed on the deterministic `services.id` (not `name`) and
the reset path now `TRUNCATE`s `services` alongside the other dynamic tables.
This means:
- Running the seed Job twice in a row (no reset in between) converges to the
same catalogue — no `services_pkey` collision.
- A `pnpm reset` followed by `pnpm seed` (or a CronJob reset fire) leaves the
catalogue exactly matching `servicesDef` (10 rows, ids `b0000001-…-001`
`…-00a`), regardless of any stale rows that were present beforehand.
- Mixed `seedKnownUsers` + full `seed()` invocations are safe — the
`demoSvcs` subset (Bath & Brush, Full Groom Small/Medium, Nail Trim) is
keyed on ids `…-001`, `…-002`, `…-003`, `…-005` and the upsert target
is `services.id`, so the same-id / different-name collision that broke
GRO-2033 (id `…-004` = "Nail Trim" vs servicesDef `…-004` =
"Full Groom — Large") cannot recur.
**UAT regression** (verify after a new image is rolled out):
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-SEED-1 | Reset → seed converges | `kubectl -n groombook exec deploy/api -- pnpm reset && pnpm seed` | Seed completes 1/1, `services` count = 10, all ids match `servicesDef` |
| TC-SEED-2 | Idempotent re-seed | Re-run `pnpm seed` without reset | Seed completes 1/1, no `services_pkey` errors, `services` count still 10 |
| TC-SEED-3 | Catalogue matches servicesDef | `psql -c "SELECT id, name FROM services ORDER BY id"` | Rows `…-001``…-00a` with names "Bath & Brush"…"Sanitary Trim" exactly as in `servicesDef` |
| TC-SEED-4 | Demo subset coexists | Run `seedKnownUsers` then full `seed` | No collision, demo subset (4 services) ends up with the same rows the full seed would write |
### 4.6 Staff Management
| # | Scenario | Steps | Expected |
+27 -12
View File
@@ -636,21 +636,28 @@ async function seedKnownUsers() {
}
}
// ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
// ── Services: idempotent upsert keyed on `id` ─────────────────────────────
// GRO-2064: previously keyed on `services.name` while writing a
// deterministic `id`. If a stale row existed with the same `id` but a
// different `name`, PostgreSQL raised `services_pkey` (id collision)
// before the name-targeted ON CONFLICT could fire. Switch the conflict
// target to `services.id` so deterministic ids always win; pair with
// `TRUNCATE services … CASCADE` above so each reset rebuilds the
// catalogue from `servicesDef` cleanly. GRO-2033 close-out.
// Id↔name map MUST stay in sync with `servicesDef` (the canonical source
// of truth in the main `seed()` function).
const demoSvcs = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
{ id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
for (const svc of demoSvcs) {
await db.insert(schema.services)
.values({ ...svc, active: true })
.onConflictDoUpdate({
target: schema.services.name,
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
target: schema.services.id,
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
});
}
console.log(`✓ Seeded ${demoSvcs.length} services`);
@@ -757,7 +764,13 @@ async function seed() {
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
);
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
// GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue
// from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows
// (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the
// same `id`) would otherwise cause the deterministic upsert to PK-collide
// on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE
// CASCADE handles appointments/invoices FKs to services.id.
await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
for (const s of allStaff) {
@@ -828,9 +841,11 @@ async function seed() {
}
// ── Services ──
// Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is
// idempotent: first run inserts, subsequent runs update existing rows.
// GRO-2064: key the upsert on `services.id` (not `name`) so deterministic
// ids always win, and rely on the TRUNCATE above to clear stale rows before
// the catalogue is rebuilt. The previous name-targeted upsert failed with
// `services_pkey` when a prior run had left a row with the same id but a
// different name (CTO review on infra PR #605, rev #4230).
const serviceIds: string[] = [];
for (const s of servicesDef) {
serviceIds.push(s.id);
@@ -844,8 +859,8 @@ async function seed() {
active: true,
})
.onConflictDoUpdate({
target: schema.services.name,
set: { description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
target: schema.services.id,
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
});
}
console.log(`✓ Created ${servicesDef.length} services`);
+27 -12
View File
@@ -747,21 +747,28 @@ async function seedKnownUsers() {
// and the full seed() UAT branch.
await seedUatStaffAccounts(db);
// ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
// ── Services: idempotent upsert keyed on `id` ─────────────────────────────
// GRO-2064: previously keyed on `services.name` while writing a
// deterministic `id`. If a stale row existed with the same `id` but a
// different `name`, PostgreSQL raised `services_pkey` (id collision)
// before the name-targeted ON CONFLICT could fire. Switch the conflict
// target to `services.id` so deterministic ids always win; pair with
// `TRUNCATE services … CASCADE` above so each reset rebuilds the
// catalogue from `servicesDef` cleanly. GRO-2033 close-out.
// Id↔name map MUST stay in sync with `servicesDef` (the canonical source
// of truth in the main `seed()` function).
const demoSvcs = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
{ id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
for (const svc of demoSvcs) {
await db.insert(schema.services)
.values({ ...svc, active: true })
.onConflictDoUpdate({
target: schema.services.name,
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
target: schema.services.id,
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
});
}
console.log(`✓ Seeded ${demoSvcs.length} services`);
@@ -868,7 +875,13 @@ async function seed() {
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
);
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
// GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue
// from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows
// (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the
// same `id`) would otherwise cause the deterministic upsert to PK-collide
// on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE
// CASCADE handles appointments/invoices FKs to services.id.
await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
for (const s of allStaff) {
@@ -919,9 +932,11 @@ async function seed() {
await seedUatStaffAccounts(db);
// ── Services ──
// Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is
// idempotent: first run inserts, subsequent runs update existing rows.
// GRO-2064: key the upsert on `services.id` (not `name`) so deterministic
// ids always win, and rely on the TRUNCATE above to clear stale rows before
// the catalogue is rebuilt. The previous name-targeted upsert failed with
// `services_pkey` when a prior run had left a row with the same id but a
// different name (CTO review on infra PR #605, rev #4230).
const serviceIds: string[] = [];
for (const s of servicesDef) {
serviceIds.push(s.id);
@@ -935,8 +950,8 @@ async function seed() {
active: true,
})
.onConflictDoUpdate({
target: schema.services.name,
set: { description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
target: schema.services.id,
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
});
}
console.log(`✓ Created ${servicesDef.length} services`);
+26 -2
View File
@@ -57,6 +57,23 @@ const createPetSchema = z.object({
customFields: z.record(z.string(), z.string()).optional(),
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
// Extended pet profile fields (api/#39, GRO-1178).
// GRO-2172: these were missing from the schema, causing POST/PATCH to
// silently drop them even though migrations 0034/0036 and seed data
// populate them. GRO-1472 was the original UAT regression.
temperamentScore: z.number().int().min(1).max(5).optional(),
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
medicalAlerts: z
.array(
z.object({
type: z.string().max(100),
description: z.string().max(1000),
severity: z.enum(["low", "medium", "high"]),
})
)
.max(50)
.optional(),
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -333,7 +350,8 @@ petsRouter.get("/:id/profile-summary", async (c) => {
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const [row] = await db
.insert(pets)
.values({
@@ -341,6 +359,10 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
customFields: customFields ?? {},
// GRO-2172: medicalAlerts shape from the API request is
// { type, description, severity } — the @groombook/types MedicalAlert
// has an optional server-generated `id`, so cast for the jsonb column.
medicalAlerts: medicalAlerts as never,
})
.returning();
return c.json(row, 201);
@@ -351,7 +373,8 @@ petsRouter.patch(
zValidator("json", updatePetSchema),
async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const [row] = await db
.update(pets)
.set({
@@ -359,6 +382,7 @@ petsRouter.patch(
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
...(customFields !== undefined ? { customFields } : {}),
medicalAlerts: medicalAlerts as never,
updatedAt: new Date(),
})
.where(eq(pets.id, c.req.param("id")))