Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 5f01df819e fix(GRO-2299): redact googleMapsApiKey from PATCH /api/admin/settings response
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Images (pull_request) Successful in 1m18s
The PATCH handler returned the full businessSettings row via .returning(),
echoing the encrypted googleMapsApiKey ciphertext back to the caller. Wrap the
return in the existing redactSettings() helper (after a !updated guard) so
redaction is applied symmetrically with the GET projection (GRO-2294).

- src/routes/settings.ts: guard + redactSettings(updated) on PATCH return
- src/__tests__/settings.test.ts: assert PATCH omits googleMapsApiKey
  (existing-row and auto-create-then-update branches)
- UAT_PLAYBOOK.md §13 TC-API-13.2: assert PATCH response omits the secret

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 06:50:20 +00:00
4 changed files with 48 additions and 319 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
"wait-for-db": "node ./scripts/wait-for-db.mjs",
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts",
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
},
+39 -114
View File
@@ -1,52 +1,13 @@
/**
* reset.ts — Drop all application tables, re-run migrations, and re-seed.
* reset.ts — Drop all application tables and re-run migrations + seed.
*
* Intended for local development only. Never run against production.
*
* Usage:
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
*
* GRO-2139: the entire drop→migrate→seed chain runs inside a single
* Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent
* `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at
* the top of the hour) cannot interleave between `reset.ts` (DROP)
* and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`.
*
* Why this matters: `seed.ts` derives every primary key from a single
* shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and
* `uuid()` in seed.ts). Two concurrent same-profile seeders therefore
* emit *identical* ids for the same logical row, and any moment
* between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the
* window in which the second seeder's INSERT can hit a pkey already
* taken by the first. Pre-GRO-2123 this raced unconditionally;
* GRO-2123 added the advisory lock around `runSeedBody` but left
* `reset.ts` and `drizzle-kit migrate` outside the lock. This script
* now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock`
* pins the lock to one reserved session and the DROP → migrate → seed
* work runs on the rest of the pool, so the lock guarantees mutual
* exclusion against any concurrent seeder for the entire chain.
*
* See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and
* `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same
* `seed.ts` code path on the same database in `groombook-dev`.
*/
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import * as schema from "./schema.js";
import {
SEED_ADVISORY_LOCK_KEY,
withSeedAdvisoryLock,
getProfile,
runSeedBody,
profiles,
} from "./seed.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const MIGRATIONS_FOLDER = resolve(__dirname, "../migrations");
import postgres from "postgres";
async function reset() {
const url = process.env.DATABASE_URL;
@@ -55,88 +16,52 @@ async function reset() {
process.exit(1);
}
if (
process.env.NODE_ENV === "production" &&
process.env.ALLOW_RESET !== "true"
) {
console.error(
"[FATAL] db:reset must not be run in production without ALLOW_RESET=true.",
);
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
process.exit(1);
}
// Pool sizing is load-bearing here. `withSeedAdvisoryLock` does
// `pool.reserve()` to pin the advisory lock to one dedicated session
// (a session-level lock released on a *different* pooled connection is
// a no-op), and the DROP / migrate / seed work then runs on the
// *remaining* pooled connections. The lock provides mutual exclusion
// across processes regardless of how many connections the work uses —
// it does NOT require the work to share the lock's session.
//
// Therefore `max` must be ≥ 2: 1 reserved for the lock + ≥1 free for
// the work. `max: 1` would let `reserve()` consume the only connection
// and every query inside the callback would block forever waiting for
// a connection that never frees (connection-starvation deadlock). We
// use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work).
const client = postgres(url, { max: 6 });
const db = drizzle(client, { schema });
const client = postgres(url, { max: 1 });
try {
await withSeedAdvisoryLock(client, async () => {
console.log("Dropping all application tables...\n");
console.log("Dropping all application tables...\n");
// Drop dependencies (tables) first
await client`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
) LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop in dependency order (children before parents)
await client`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
) LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop custom enums
await client`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT typname FROM pg_type
WHERE typtype = 'e' AND typnamespace = (
SELECT oid FROM pg_namespace WHERE nspname = 'public'
)
) LOOP
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop custom enums
await client`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT typname FROM pg_type
WHERE typtype = 'e' AND typnamespace = (
SELECT oid FROM pg_namespace WHERE nspname = 'public'
)
) LOOP
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop the drizzle migrations tracking table
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
// Drop the drizzle migrations tracking table
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
console.log("✓ All tables and enums dropped\n");
console.log("✓ All tables and enums dropped\n");
console.log("Running migrations...");
await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
console.log("✓ Migrations applied\n");
console.log("Seeding database...");
const profile = getProfile();
const cfg = profiles[profile];
await runSeedBody(client, db, profile, cfg);
});
console.log(
`\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`,
);
} finally {
await client.end();
}
await client.end();
}
reset().catch((err) => {
+6 -178
View File
@@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types";
// ── Seed profile configuration ─────────────────────────────────────────────
export type SeedProfile = "dev" | "uat" | "demo";
type SeedProfile = "dev" | "uat" | "demo";
export interface ProfileConfig {
interface ProfileConfig {
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
clientCount: number;
appointmentsBackDays: number;
@@ -35,7 +35,7 @@ export interface ProfileConfig {
includeUatClients: boolean;
}
export const profiles: Record<SeedProfile, ProfileConfig> = {
const profiles: Record<SeedProfile, ProfileConfig> = {
dev: {
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
clientCount: 100,
@@ -70,8 +70,6 @@ function getProfile(): SeedProfile {
return "uat";
}
export { getProfile };
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
/**
@@ -832,168 +830,6 @@ async function seedUatGroomerLinkage(
);
}
// ── GRO-2311 / GRO-2313: portal customer StatusBadge coverage ────────────────
/**
* GRO-2311 / GRO-2313: give the UAT portal customer (`uat-customer@groombook.dev`)
* a deterministic spread of appointments so the customer-portal StatusBadge
* palette can be LIVE-observed (not just code-verified against the bundle).
*
* Scope is the subset of badge states reachable from the `appointment_status`
* enum (`scheduled, confirmed, in_progress, completed, cancelled, no_show`) —
* the portal's <StatusBadge> renders `appointment.status` verbatim. `pending`
* and `waitlisted` are NOT valid appointment statuses and cannot be seeded; the
* styled `no_show`→`no-show` badge fix and any pending/waitlisted derivation are
* tracked separately in GRO-2319 (web). CTO-approved Option A on GRO-2313.
*
* - confirmed → future startTime → renders as an Upcoming card (Confirmed badge)
* - scheduled → future startTime → renders as an Upcoming card (Scheduled badge)
* - cancelled → past startTime → Past tab (isUpcoming excludes cancelled)
* - no_show → past startTime → Past tab (raw `no_show` label until GRO-2319)
*
* The existing GRO-2100 `completed` appointment (a0000001-…-0001) is left
* untouched (AC #4), so Completed is also covered.
*
* Idempotent: each appointment uses a fixed UUID and is upserted with
* onConflictDoNothing, so the hourly reset-demo-data CronJob (which TRUNCATEs
* then re-seeds) and non-truncating dev re-seeds never dup-key
* (see GRO-2033 for the dup-key class).
*/
async function seedUatCustomerPortalAppointments(
db: ReturnType<typeof drizzle>,
customerClientId: string | null,
): Promise<void> {
const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha
// Skip silently outside the UAT persona profile (e.g. a dev/test seed that
// never created the UAT Customer client).
if (!customerClientId) {
return;
}
// The customer's pet must exist (pets are NOT truncated on reset, so this is
// stable). Defensive: bail cleanly if the persona pet is absent.
const [linkedPet] = await db
.select({ id: schema.pets.id })
.from(schema.pets)
.where(eq(schema.pets.id, LINKED_PET_ID))
.limit(1);
if (!linkedPet) {
console.warn(`⚠ GRO-2311: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping portal appointment seed`);
return;
}
// Stable "Bath & Brush" service; fall back to any active service.
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
const [bathService] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
.limit(1);
let serviceId: string;
if (bathService) {
serviceId = bathService.id;
} else {
const [fallback] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.active, true))
.limit(1);
if (!fallback) {
console.warn(`⚠ GRO-2311: no active services found — skipping portal appointment seed`);
return;
}
serviceId = fallback.id;
}
// Attach the UAT groomer when present (nicer "with <groomer>" card); else null
// ("First Available"). Either way these are the customer's own appointments —
// no new groomer↔pet linkage invariant is created (uses the already-linked
// Pup Alpha), so GRO-1987 TC-UAT-3 (403 on the UNLINKED Pup Beta) is unaffected.
const [uatGroomerStaff] = await db
.select({ id: schema.staff.id })
.from(schema.staff)
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
.limit(1);
const staffId = uatGroomerStaff?.id ?? null;
// Anchor all times to local wall-clock so future/past holds regardless of the
// hourly reset cadence.
const at = (deltaDays: number, hour: number): Date => {
const d = new Date();
d.setDate(d.getDate() + deltaDays);
d.setHours(hour, 0, 0, 0);
return d;
};
const DURATION_MS = 45 * 60 * 1000;
const rows = [
{
id: "a0000001-0000-0000-0000-000000000002",
status: "confirmed" as const,
start: at(3, 10),
confirmationStatus: "confirmed",
confirmedAt: new Date(),
cancelledAt: null as Date | null,
notes: "GRO-2311: upcoming confirmed appointment for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000003",
status: "scheduled" as const,
start: at(5, 14),
confirmationStatus: "pending",
confirmedAt: null as Date | null,
cancelledAt: null as Date | null,
notes: "GRO-2311: upcoming scheduled appointment for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000004",
status: "cancelled" as const,
start: at(-3, 11),
confirmationStatus: "cancelled",
confirmedAt: null as Date | null,
cancelledAt: new Date(),
notes: "GRO-2311: cancelled appointment (Past tab) for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000005",
status: "no_show" as const,
start: at(-10, 9),
confirmationStatus: "confirmed",
confirmedAt: null as Date | null,
cancelledAt: null as Date | null,
notes: "GRO-2311: no_show appointment (Past tab) for portal StatusBadge coverage.",
},
];
await db
.insert(schema.appointments)
.values(
rows.map((r) => ({
id: r.id,
clientId: customerClientId,
petId: LINKED_PET_ID,
serviceId,
staffId,
batherStaffId: null,
status: r.status,
startTime: r.start,
endTime: new Date(r.start.getTime() + DURATION_MS),
notes: r.notes,
priceCents: null,
confirmationStatus: r.confirmationStatus,
confirmedAt: r.confirmedAt,
cancelledAt: r.cancelledAt,
})),
)
.onConflictDoNothing({ target: schema.appointments.id });
console.log(
`✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`,
);
}
// ── GRO-2225: deterministic route-optimization cohort ────────────────────────
/**
@@ -1275,10 +1111,6 @@ async function seedKnownUsers() {
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
// appointment statuses only). Runs after the groomer linkage so the customer
// client + Pup Alpha already exist.
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
// ── Client: Demo Client ──
const [existingClient] = await db
@@ -1362,7 +1194,7 @@ async function seedKnownUsers() {
// from runbooks without ambiguity and binds to the single-argument
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
// number (no bigint type plumbing required).
export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
/**
* Reserve a dedicated connection from `pool`, take the seed advisory lock
@@ -1375,7 +1207,7 @@ export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitra
* for the lock and release it from the same reserved connection. The
* seed work itself still runs on the pooled connections.
*/
export async function withSeedAdvisoryLock<T>(
async function withSeedAdvisoryLock<T>(
pool: ReturnType<typeof postgres>,
fn: () => Promise<T>,
): Promise<T> {
@@ -1433,7 +1265,7 @@ async function seed() {
await client.end();
}
export async function runSeedBody(
async function runSeedBody(
client: ReturnType<typeof postgres>,
db: ReturnType<typeof drizzle>,
profile: SeedProfile,
@@ -1541,10 +1373,6 @@ export async function runSeedBody(
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
// appointment statuses only). Runs after the groomer linkage so the customer
// client + Pup Alpha already exist.
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
// GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments
// for the UAT groomer. Must run AFTER services are seeded (it looks up a
+2 -26
View File
@@ -57,23 +57,6 @@ 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 });
@@ -350,8 +333,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.insert(pets)
.values({
@@ -359,10 +341,6 @@ 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);
@@ -373,8 +351,7 @@ petsRouter.patch(
zValidator("json", updatePetSchema),
async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.update(pets)
.set({
@@ -382,7 +359,6 @@ 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")))