Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7523263072 | |||
| d61607f4c5 | |||
| 2853ce73a5 | |||
| 1e0747324d | |||
| b4b48f7b50 | |||
| fe412933ea | |||
| cd2f60e282 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"type": "http",
|
||||
"url": "https://git-mcp.farh.net/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITEA_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -133,6 +133,7 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
||||
| TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) |
|
||||
| TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) |
|
||||
| TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` |
|
||||
| TC-API-2.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500** — `processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit |
|
||||
| TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden |
|
||||
| TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field |
|
||||
| TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) |
|
||||
@@ -165,6 +166,8 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
||||
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
|
||||
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
|
||||
| TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
|
||||
| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
|
||||
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
|
||||
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
|
||||
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
|
||||
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
|
||||
@@ -284,6 +287,7 @@ This means:
|
||||
| TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted |
|
||||
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
|
||||
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
|
||||
| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. |
|
||||
|
||||
### 4.9 Waitlist
|
||||
|
||||
@@ -329,8 +333,8 @@ This means:
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned |
|
||||
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
|
||||
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present |
|
||||
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned |
|
||||
| TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored |
|
||||
| TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned |
|
||||
| TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed |
|
||||
|
||||
@@ -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 && drizzle-kit migrate && tsx src/seed.ts",
|
||||
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts",
|
||||
"studio": "drizzle-kit studio",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
+114
-39
@@ -1,13 +1,52 @@
|
||||
/**
|
||||
* reset.ts — Drop all application tables and re-run migrations + seed.
|
||||
* reset.ts — Drop all application tables, re-run migrations, and re-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");
|
||||
|
||||
async function reset() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
@@ -16,52 +55,88 @@ 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);
|
||||
}
|
||||
|
||||
const client = postgres(url, { max: 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 });
|
||||
|
||||
console.log("Dropping all application tables...\n");
|
||||
try {
|
||||
await withSeedAdvisoryLock(client, async () => {
|
||||
console.log("Dropping all application tables...\n");
|
||||
|
||||
// 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 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 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");
|
||||
|
||||
await client.end();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
reset().catch((err) => {
|
||||
|
||||
+218
-6
@@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
type SeedProfile = "dev" | "uat" | "demo";
|
||||
export type SeedProfile = "dev" | "uat" | "demo";
|
||||
|
||||
interface ProfileConfig {
|
||||
export interface ProfileConfig {
|
||||
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
|
||||
clientCount: number;
|
||||
appointmentsBackDays: number;
|
||||
@@ -35,7 +35,7 @@ interface ProfileConfig {
|
||||
includeUatClients: boolean;
|
||||
}
|
||||
|
||||
const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||
export const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||
dev: {
|
||||
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
||||
clientCount: 100,
|
||||
@@ -70,6 +70,8 @@ function getProfile(): SeedProfile {
|
||||
return "uat";
|
||||
}
|
||||
|
||||
export { getProfile };
|
||||
|
||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -830,6 +832,208 @@ 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).
|
||||
*
|
||||
* `appointment_status` enum is (`scheduled, confirmed, in_progress, completed,
|
||||
* cancelled, no_show`) — the portal's <StatusBadge> renders `appointment.status`
|
||||
* verbatim. `pending` and `waitlisted` are NOT valid appointment statuses, so
|
||||
* GRO-2319 derives them in the portal: `pending` from an upcoming appointment's
|
||||
* `confirmationStatus` (the `scheduled` row below carries `pending`), and
|
||||
* `waitlisted` from an ACTIVE `waitlist_entries` row (seeded at the end of this
|
||||
* function) which `GET /api/portal/appointments` surfaces as a synthetic card.
|
||||
* The `no_show`→`no-show` badge-key fix is the web side of GRO-2319.
|
||||
*
|
||||
* - 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-2319 item 2: seed one ACTIVE waitlist entry so the portal's `waitlisted`
|
||||
// card (surfaced by GET /api/portal/appointments) is live-observable. Unlike
|
||||
// appointments, `waitlist_entries` is NOT truncated on the hourly reset, so we
|
||||
// upsert by fixed id and REFRESH the preferred date to a future-relative value
|
||||
// each reset — otherwise the date would go stale and the card would drop out of
|
||||
// the Upcoming list. (The seeded `scheduled` appointment above already carries
|
||||
// `confirmationStatus: "pending"`, which drives the live Pending badge.)
|
||||
const WAITLIST_ENTRY_ID = "e0000001-0000-0000-0000-000000000001";
|
||||
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
||||
const wlStart = at(7, 13); // 7 days out, 1pm — comfortably "upcoming"
|
||||
const wlPreferredDate = `${wlStart.getFullYear()}-${pad2(wlStart.getMonth() + 1)}-${pad2(wlStart.getDate())}`;
|
||||
const wlPreferredTime = `${pad2(wlStart.getHours())}:00:00`;
|
||||
|
||||
await db
|
||||
.insert(schema.waitlistEntries)
|
||||
.values({
|
||||
id: WAITLIST_ENTRY_ID,
|
||||
clientId: customerClientId,
|
||||
petId: LINKED_PET_ID,
|
||||
serviceId,
|
||||
preferredDate: wlPreferredDate,
|
||||
preferredTime: wlPreferredTime,
|
||||
status: "active",
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.waitlistEntries.id,
|
||||
set: {
|
||||
preferredDate: wlPreferredDate,
|
||||
preferredTime: wlPreferredTime,
|
||||
status: "active",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✓ GRO-2319: seeded 1 active waitlist entry (${wlPreferredDate} ${wlPreferredTime}) for UAT customer portal Waitlisted card`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── GRO-2225: deterministic route-optimization cohort ────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1111,6 +1315,10 @@ 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
|
||||
@@ -1194,7 +1402,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).
|
||||
const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
|
||||
export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
|
||||
|
||||
/**
|
||||
* Reserve a dedicated connection from `pool`, take the seed advisory lock
|
||||
@@ -1207,7 +1415,7 @@ const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, sta
|
||||
* for the lock and release it from the same reserved connection. The
|
||||
* seed work itself still runs on the pooled connections.
|
||||
*/
|
||||
async function withSeedAdvisoryLock<T>(
|
||||
export async function withSeedAdvisoryLock<T>(
|
||||
pool: ReturnType<typeof postgres>,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
@@ -1265,7 +1473,7 @@ async function seed() {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
async function runSeedBody(
|
||||
export async function runSeedBody(
|
||||
client: ReturnType<typeof postgres>,
|
||||
db: ReturnType<typeof drizzle>,
|
||||
profile: SeedProfile,
|
||||
@@ -1373,6 +1581,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
||||
// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the
|
||||
// documented maximum (500) before invoking the geocoding service. We mock the
|
||||
// service to capture the exact limit the route forwards.
|
||||
|
||||
const geocodeUngeocodedClients = vi.fn(async () => ({
|
||||
totalRemaining: 0,
|
||||
processed: 0,
|
||||
geocoded: 0,
|
||||
failed: 0,
|
||||
remaining: 0,
|
||||
}));
|
||||
|
||||
vi.mock("../services/clientGeocoding.js", () => ({
|
||||
geocodeUngeocodedClients,
|
||||
geocodeClient: vi.fn(),
|
||||
resolveClientGeocodingProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const tableProxy = (name: string) =>
|
||||
new Proxy(
|
||||
{ _name: name },
|
||||
{ get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) }
|
||||
);
|
||||
return {
|
||||
getDb: () => ({}),
|
||||
clients: tableProxy("clients"),
|
||||
appointments: tableProxy("appointments"),
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
or: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { clientsRouter } = await import("../routes/clients.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/clients", clientsRouter);
|
||||
|
||||
function postBatch(query: string) {
|
||||
return app.request(`/clients/geocode-batch${query}`, { method: "POST" });
|
||||
}
|
||||
|
||||
describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => {
|
||||
beforeEach(() => {
|
||||
geocodeUngeocodedClients.mockClear();
|
||||
});
|
||||
|
||||
it("defaults to 50 when no ?limit is supplied", async () => {
|
||||
const res = await postBatch("");
|
||||
expect(res.status).toBe(200);
|
||||
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50);
|
||||
});
|
||||
|
||||
it("passes through a value within the cap", async () => {
|
||||
const res = await postBatch("?limit=120");
|
||||
expect(res.status).toBe(200);
|
||||
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120);
|
||||
});
|
||||
|
||||
it("clamps an over-cap value to 500", async () => {
|
||||
const res = await postBatch("?limit=100000");
|
||||
expect(res.status).toBe(200);
|
||||
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500);
|
||||
});
|
||||
|
||||
it("floors a fractional value before clamping", async () => {
|
||||
const res = await postBatch("?limit=49.9");
|
||||
expect(res.status).toBe(200);
|
||||
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49);
|
||||
});
|
||||
|
||||
it("rejects a non-positive limit with 400", async () => {
|
||||
const res = await postBatch("?limit=0");
|
||||
expect(res.status).toBe(400);
|
||||
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-numeric limit with 400", async () => {
|
||||
const res = await postBatch("?limit=abc");
|
||||
expect(res.status).toBe(400);
|
||||
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,11 +39,17 @@ const APPOINTMENT = {
|
||||
|
||||
let selectSessionRow: Record<string, unknown> | null = null;
|
||||
let selectAppointmentRow: Record<string, unknown> | null = null;
|
||||
let selectWaitlistRows: Record<string, unknown>[] = [];
|
||||
let selectPetRows: Record<string, unknown>[] = [];
|
||||
let selectStaffRows: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
selectSessionRow = null;
|
||||
selectAppointmentRow = null;
|
||||
selectWaitlistRows = [];
|
||||
selectPetRows = [];
|
||||
selectStaffRows = [];
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
@@ -72,6 +78,12 @@ vi.mock("@groombook/db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
const mkTable = (name: string) =>
|
||||
new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) });
|
||||
const waitlistEntries = mkTable("waitlistEntries");
|
||||
const pets = mkTable("pets");
|
||||
const staff = mkTable("staff");
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
@@ -82,6 +94,15 @@ vi.mock("@groombook/db", () => {
|
||||
if (table._name === "appointments") {
|
||||
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
|
||||
}
|
||||
if (table._name === "waitlistEntries") {
|
||||
return makeChainable(selectWaitlistRows);
|
||||
}
|
||||
if (table._name === "pets") {
|
||||
return makeChainable(selectPetRows);
|
||||
}
|
||||
if (table._name === "staff") {
|
||||
return makeChainable(selectStaffRows);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
@@ -102,8 +123,12 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
impersonationSessions,
|
||||
appointments,
|
||||
waitlistEntries,
|
||||
pets,
|
||||
staff,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -125,6 +150,54 @@ function jsonPatch(path: string, body: unknown, headers?: Record<string, string>
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as
|
||||
// synthetic `waitlisted` cards, so GET /portal/appointments must surface them.
|
||||
describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => {
|
||||
it("returns active waitlist entries as synthetic waitlisted cards", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
selectWaitlistRows = [
|
||||
{
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
petId: "pet-1",
|
||||
serviceId: "svc-1",
|
||||
preferredDate: "2099-01-01",
|
||||
preferredTime: "13:00:00",
|
||||
},
|
||||
];
|
||||
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
|
||||
|
||||
const res = await app.request("/portal/appointments", {
|
||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const waitlistCard = body.appointments.find(
|
||||
(a: { status: string }) => a.status === "waitlisted",
|
||||
);
|
||||
expect(waitlistCard).toBeTruthy();
|
||||
expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111");
|
||||
expect(waitlistCard.pet.name).toBe("Rex");
|
||||
expect(waitlistCard.confirmationStatus).toBeNull();
|
||||
// startTime is derived from preferredDate + preferredTime so the card sorts
|
||||
// and classifies as Upcoming.
|
||||
expect(waitlistCard.startTime).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the waitlist section when the client has no active entries", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
selectWaitlistRows = [];
|
||||
|
||||
const res = await app.request("/portal/appointments", {
|
||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /portal/appointments/:id/notes", () => {
|
||||
it("returns updated appointment with safe fields only", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
||||
// GRO-2294: GET /api/admin/settings must not return the encrypted
|
||||
// googleMapsApiKey ciphertext, on either the existing-row or auto-create branch.
|
||||
|
||||
let selectRows: Record<string, unknown>[] = [];
|
||||
let insertReturning: Record<string, unknown>[] = [];
|
||||
let updateReturning: Record<string, unknown>[] = [];
|
||||
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy passthrough
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const businessSettings = new Proxy(
|
||||
{ _name: "business_settings" },
|
||||
{ get: (_t, p) => (p === "_name" ? "business_settings" : { column: p }) }
|
||||
);
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({ from: () => makeChainable(selectRows) }),
|
||||
insert: () => ({
|
||||
values: () => ({ returning: () => insertReturning }),
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({ where: () => ({ returning: () => updateReturning }) }),
|
||||
}),
|
||||
}),
|
||||
businessSettings,
|
||||
eq: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/s3.js", () => ({
|
||||
getPresignedUploadUrl: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
putObject: vi.fn(),
|
||||
getObject: vi.fn(),
|
||||
}));
|
||||
|
||||
const { settingsRouter } = await import("../routes/settings.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/settings", settingsRouter);
|
||||
|
||||
// PATCH /settings is guarded by requireSuperUser(), which reads the staff record
|
||||
// from context. Inject a super-user staff row so the handler runs.
|
||||
const patchApp = new Hono<{
|
||||
Variables: { staff: { id: string; isSuperUser: boolean } };
|
||||
}>();
|
||||
patchApp.use("*", async (c, next) => {
|
||||
c.set("staff", { id: "staff-1", isSuperUser: true });
|
||||
await next();
|
||||
});
|
||||
patchApp.route("/settings", settingsRouter);
|
||||
|
||||
const FULL_ROW = {
|
||||
id: "settings-uuid-1",
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
routeOptimizationProvider: "google",
|
||||
googleMapsApiKey: "ENCRYPTED::super-secret-ciphertext",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => {
|
||||
beforeEach(() => {
|
||||
selectRows = [];
|
||||
insertReturning = [];
|
||||
});
|
||||
|
||||
it("omits googleMapsApiKey from an existing settings row", async () => {
|
||||
selectRows = [{ ...FULL_ROW }];
|
||||
const res = await app.request("/settings", { method: "GET" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty("googleMapsApiKey");
|
||||
// Non-secret fields are still returned.
|
||||
expect(body.businessName).toBe("GroomBook");
|
||||
expect(body.routeOptimizationProvider).toBe("google");
|
||||
});
|
||||
|
||||
it("omits googleMapsApiKey from the auto-create branch", async () => {
|
||||
selectRows = [];
|
||||
insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }];
|
||||
const res = await app.request("/settings", { method: "GET" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty("googleMapsApiKey");
|
||||
expect(body.id).toBe("settings-uuid-new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => {
|
||||
beforeEach(() => {
|
||||
selectRows = [];
|
||||
insertReturning = [];
|
||||
updateReturning = [];
|
||||
});
|
||||
|
||||
function patchRequest(body: Record<string, unknown>) {
|
||||
return patchApp.request("/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("omits googleMapsApiKey from the PATCH response", async () => {
|
||||
selectRows = [{ ...FULL_ROW }];
|
||||
updateReturning = [{ ...FULL_ROW, businessName: "Updated Name" }];
|
||||
const res = await patchRequest({ businessName: "Updated Name" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty("googleMapsApiKey");
|
||||
// Non-secret updated fields are still returned.
|
||||
expect(body.businessName).toBe("Updated Name");
|
||||
expect(body.routeOptimizationProvider).toBe("google");
|
||||
});
|
||||
|
||||
it("omits googleMapsApiKey on the auto-create-then-update branch", async () => {
|
||||
selectRows = [];
|
||||
insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }];
|
||||
updateReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }];
|
||||
const res = await patchRequest({ primaryColor: "#123456" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty("googleMapsApiKey");
|
||||
expect(body.id).toBe("settings-uuid-new");
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -12,6 +12,12 @@ import {
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how
|
||||
// long one synchronous request stays open and the per-request external API cost
|
||||
// when routeOptimizationProvider = "google".
|
||||
const GEOCODE_BATCH_DEFAULT_LIMIT = 50;
|
||||
const GEOCODE_BATCH_MAX_LIMIT = 500;
|
||||
|
||||
type ClientRow = typeof clients.$inferSelect;
|
||||
|
||||
/**
|
||||
@@ -185,12 +191,15 @@ clientsRouter.post("/:clientId/geocode", async (c) => {
|
||||
clientsRouter.post("/geocode-batch", async (c) => {
|
||||
const db = getDb();
|
||||
const limitRaw = c.req.query("limit");
|
||||
let limit = 50;
|
||||
let limit = GEOCODE_BATCH_DEFAULT_LIMIT;
|
||||
if (limitRaw !== undefined) {
|
||||
limit = Number(limitRaw);
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return c.json({ error: "limit must be a positive integer" }, 400);
|
||||
}
|
||||
// Clamp to the documented maximum to bound synchronous request duration
|
||||
// and (for the Google provider) per-request external API cost.
|
||||
limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT);
|
||||
}
|
||||
const summary = await geocodeUngeocodedClients(db, limit);
|
||||
return c.json(summary);
|
||||
|
||||
+26
-2
@@ -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")))
|
||||
|
||||
+45
-3
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { and, eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
@@ -195,7 +195,29 @@ portalRouter.get("/appointments", async (c) => {
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||
// GRO-2319: surface the client's ACTIVE waitlist entries alongside their
|
||||
// appointments so the portal can render them as `waitlisted` cards in the
|
||||
// Upcoming list. The `appointment_status` enum cannot represent `waitlisted`,
|
||||
// so these are synthetic entries (status hard-set to `waitlisted`, id prefixed
|
||||
// `waitlist:`) derived from `waitlist_entries`.
|
||||
const waitlistRows = await db
|
||||
.select({
|
||||
id: waitlistEntries.id,
|
||||
petId: waitlistEntries.petId,
|
||||
serviceId: waitlistEntries.serviceId,
|
||||
preferredDate: waitlistEntries.preferredDate,
|
||||
preferredTime: waitlistEntries.preferredTime,
|
||||
})
|
||||
.from(waitlistEntries)
|
||||
.where(
|
||||
and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")),
|
||||
);
|
||||
|
||||
// Pet lookups must cover both appointment and waitlist pets.
|
||||
const petIds = [
|
||||
...allAppts.map(a => a.petId).filter((id): id is string => id !== null),
|
||||
...waitlistRows.map(w => w.petId),
|
||||
];
|
||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||
|
||||
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||
@@ -217,7 +239,27 @@ portalRouter.get("/appointments", async (c) => {
|
||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||
}));
|
||||
|
||||
return c.json({ appointments: appts });
|
||||
// Derive a display `startTime` from the entry's preferred date/time so the
|
||||
// portal can sort/classify the synthetic card (an invalid combination simply
|
||||
// yields a null startTime, which the portal tolerates).
|
||||
const waitlistAppts = waitlistRows.map(w => {
|
||||
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
|
||||
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
return {
|
||||
id: `waitlist:${w.id}`,
|
||||
startTime,
|
||||
endTime: null,
|
||||
status: "waitlisted" as const,
|
||||
confirmationStatus: null,
|
||||
customerNotes: null,
|
||||
notes: null,
|
||||
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
|
||||
service: { id: w.serviceId },
|
||||
staff: null,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ appointments: [...appts, ...waitlistAppts] });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
|
||||
+16
-3
@@ -7,6 +7,17 @@ import { requireSuperUser } from "../middleware/rbac.js";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
|
||||
type BusinessSettingsRow = typeof businessSettings.$inferSelect;
|
||||
|
||||
// Strip the encrypted googleMapsApiKey ciphertext from settings responses
|
||||
// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is
|
||||
// only written via the dedicated provider-config endpoint.
|
||||
function redactSettings(row: BusinessSettingsRow) {
|
||||
const rest: Partial<BusinessSettingsRow> = { ...row };
|
||||
delete rest.googleMapsApiKey;
|
||||
return rest;
|
||||
}
|
||||
|
||||
// GET /api/admin/settings — return current business settings
|
||||
settingsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
@@ -14,9 +25,10 @@ settingsRouter.get("/", async (c) => {
|
||||
if (!row) {
|
||||
// Auto-create default settings if none exist
|
||||
const [created] = await db.insert(businessSettings).values({}).returning();
|
||||
return c.json(created);
|
||||
if (!created) throw new Error("Failed to create default settings");
|
||||
return c.json(redactSettings(created));
|
||||
}
|
||||
return c.json(row);
|
||||
return c.json(redactSettings(row));
|
||||
});
|
||||
|
||||
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
@@ -53,7 +65,8 @@ settingsRouter.patch(
|
||||
.where(eq(businessSettings.id, settingsId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
if (!updated) throw new Error("Failed to update settings");
|
||||
return c.json(redactSettings(updated));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user