Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf064b3ada | |||
| 4df7d96020 |
@@ -118,17 +118,6 @@ jobs:
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||
|
||||
- name: Smoke test migrate image (blackhole npmjs.org)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm \
|
||||
--add-host registry.npmjs.org:127.0.0.1 \
|
||||
--entrypoint="" \
|
||||
"$IMAGE" \
|
||||
pnpm --version
|
||||
|
||||
- name: Build and push Seed image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
||||
+4
-9
@@ -1,7 +1,5 @@
|
||||
FROM node:22-alpine AS base
|
||||
RUN corepack enable && corepack install -g pnpm@9.15.4
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV COREPACK_ENABLE_STRICT=0
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps
|
||||
@@ -13,6 +11,7 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY src/ src/
|
||||
COPY tsconfig.json ./
|
||||
@@ -22,9 +21,7 @@ RUN pnpm --filter @groombook/types build && \
|
||||
|
||||
# Runtime
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack install -g pnpm@9.15.4
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV COREPACK_ENABLE_STRICT=0
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -53,7 +50,5 @@ CMD ["pnpm", "--filter", "@groombook/db", "seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
RUN corepack enable && corepack install -g pnpm@9.15.4
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV COREPACK_ENABLE_STRICT=0
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||
|
||||
@@ -114,9 +114,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-3.22 | Verify medicalAlerts shape | GET /api/pets/{id} for any pet with non-empty medicalAlerts | medicalAlerts is an array; each entry has type, description, severity |
|
||||
| TC-API-3.23 | Verify UAT test pet Charlie has behavioral alert | GET /api/pets/{id} where name = "TestCooper" (pet for uat-charlie@groombook.dev) | medicalAlerts includes an entry with type: "behavioral", severity: "low" or "high" |
|
||||
| TC-API-3.24 | Verify UAT test pet Delta has skin alert | GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) | medicalAlerts includes an entry with type: "skin" |
|
||||
| TC-API-3.25 | Verify 30+ total pets in UAT DB | GET /api/pets then count total | 30+ pets returned (UAT seed creates 500 random-pool + 5 UAT test clients + 2 UAT customer = 507 total) |
|
||||
| TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) |
|
||||
| TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` |
|
||||
|
||||
### 4.4 Appointment Scheduling
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Migration: 0035_add_missing_coat_type_values.sql
|
||||
-- Adds missing values to coat_type enum that seed.ts requires but which were
|
||||
-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift).
|
||||
-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless'
|
||||
-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky'
|
||||
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Migration: 0035_add_short_to_coat_type_enum.sql
|
||||
-- GRO-1953: Adds missing "short" value to the coat_type enum so that seed data
|
||||
-- (which uses coatTypePool including "short") can be inserted without error.
|
||||
--
|
||||
-- The seed file defines coatTypePool as:
|
||||
-- ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]
|
||||
-- but migration 0031 created the enum without "short", causing:
|
||||
-- PostgresError: invalid input value for enum coat_type: "short"
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
|
||||
|
||||
COMMIT;
|
||||
@@ -246,13 +246,6 @@
|
||||
"when": 1751140800000,
|
||||
"tag": "0034_extend_pet_profile_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1751480000000,
|
||||
"tag": "0035_add_missing_coat_type_values",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+93
-211
@@ -270,10 +270,6 @@ const medicalAlertPool: MedicalAlert[] = [
|
||||
{ id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" },
|
||||
{ id: "", type: "other", description: "Luxating patella — short walks only", severity: "medium" },
|
||||
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
|
||||
{ id: "", type: "behavioral", description: "Anxiety — calm environment preferred", severity: "low" },
|
||||
{ id: "", type: "behavioral", description: "Fear-based aggression — approach with caution", severity: "high" },
|
||||
{ id: "", type: "skin", description: "Contact dermatitis — avoid harsh chemicals", severity: "medium" },
|
||||
{ id: "", type: "skin", description: "Hot spots — monitor and report any worsening", severity: "high" },
|
||||
];
|
||||
|
||||
const preferredCutPool: string[] = [
|
||||
@@ -389,19 +385,78 @@ const servicesDef = [
|
||||
{ id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
|
||||
];
|
||||
|
||||
// ── UAT staff account seeding (shared between seed paths) ─────────────────────
|
||||
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seeds or upserts the deterministic UAT staff accounts with numeric OIDC subs
|
||||
* from SEED_UAT_*_OIDC_SUB / SEED_UAT_GROOMER_OIDC_SUBS env vars.
|
||||
*
|
||||
* In the full seed path this must run AFTER random staff are created so the
|
||||
* deterministic upserts land on the correct rows (groomers referenced by the
|
||||
* UAT test-client appointment logic use groomers[0] etc.).
|
||||
*
|
||||
* In seedKnownUsers() this replaces the inline UAT-staff block.
|
||||
* Seeds only the minimal known users for prod/demo environments.
|
||||
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
|
||||
* Idempotent: skips creation if records already exist.
|
||||
*/
|
||||
async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
|
||||
async function seedKnownUsers() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(url, { max: 5 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
console.log("Seeding known users (prod/demo mode)...\n");
|
||||
|
||||
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
|
||||
|
||||
// ── Staff: Demo Manager ──
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
if (existingStaff) {
|
||||
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: KNOWN_STAFF_ID,
|
||||
name: "Demo Manager",
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
||||
}
|
||||
|
||||
// ── Staff: SEED_ADMIN_EMAIL admin ──
|
||||
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
||||
if (adminEmail) {
|
||||
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
|
||||
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const [existingAdmin] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, adminEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ──
|
||||
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
|
||||
if (uatSuperOidcSub) {
|
||||
@@ -569,184 +624,6 @@ async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client: UAT Customer ─────────────────────────────────────────────────────
|
||||
// Only uat-customer is a real end-user who needs a clients row.
|
||||
// uat-groomer and uat-super are staff — they have staff records, not client records.
|
||||
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
|
||||
const [uatCustomerRow] = await db
|
||||
.select()
|
||||
.from(schema.clients)
|
||||
.where(eq(schema.clients.email, "uat-customer@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
let uatCustomerClientId: string;
|
||||
if (uatCustomerRow) {
|
||||
uatCustomerClientId = uatCustomerRow.id;
|
||||
console.log(`✓ UAT Customer client record already exists — skipping`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(schema.clients)
|
||||
.values({
|
||||
id: UAT_CUSTOMER_ID,
|
||||
email: "uat-customer@groombook.dev",
|
||||
name: "UAT Customer",
|
||||
phone: "555-0102",
|
||||
address: "1 UAT Lane, Test City, CA 90210",
|
||||
})
|
||||
.returning();
|
||||
uatCustomerClientId = created!.id;
|
||||
console.log(`✓ Created client 'UAT Customer' for SSO bridge`);
|
||||
}
|
||||
|
||||
// ── Pets: UAT Customer's dogs ────────────────────────────────────────────────
|
||||
const uatCustomerPets = [
|
||||
{ id: "c0000001-0000-0000-0000-000000000002", name: "UAT Pup Alpha", species: "Dog", breed: "Beagle", weight: "12.00", dob: "2022-03-10", image: "/demo-pets/dog-beagle.png" },
|
||||
{ id: "c0000001-0000-0000-0000-000000000003", name: "UAT Pup Beta", species: "Dog", breed: "Labrador", weight: "28.00", dob: "2021-07-22", image: "/demo-pets/dog-labrador.png" },
|
||||
];
|
||||
for (const pet of uatCustomerPets) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.pets)
|
||||
.where(eq(schema.pets.id, pet.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
// Upsert so extended fields are always populated on re-runs
|
||||
await db.insert(schema.pets)
|
||||
.values({
|
||||
id: pet.id,
|
||||
clientId: uatCustomerClientId,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
weightKg: pet.weight,
|
||||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||||
image: pet.image,
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: [],
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.pets.id,
|
||||
set: {
|
||||
clientId: uatCustomerClientId,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
weightKg: pet.weight,
|
||||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||||
image: pet.image,
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: [],
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
},
|
||||
});
|
||||
console.log(`✓ Upserted UAT pet '${pet.name}' with extended fields`);
|
||||
} else {
|
||||
await db.insert(schema.pets).values({
|
||||
id: pet.id,
|
||||
clientId: uatCustomerClientId,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
weightKg: pet.weight,
|
||||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||||
image: pet.image,
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: [],
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
});
|
||||
console.log(`✓ Created UAT pet '${pet.name}' with extended fields`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seeds only the minimal known users for prod/demo environments.
|
||||
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
|
||||
* Idempotent: skips creation if records already exist.
|
||||
*/
|
||||
async function seedKnownUsers() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(url, { max: 5 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
console.log("Seeding known users (prod/demo mode)...\n");
|
||||
|
||||
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
|
||||
|
||||
// ── Staff: Demo Manager ──
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
if (existingStaff) {
|
||||
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: KNOWN_STAFF_ID,
|
||||
name: "Demo Manager",
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
||||
}
|
||||
|
||||
// ── Staff: SEED_ADMIN_EMAIL admin ──
|
||||
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
||||
if (adminEmail) {
|
||||
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
|
||||
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const [existingAdmin] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, adminEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
|
||||
// Extracted into seedUatStaffAccounts() so it runs in both 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.
|
||||
@@ -913,10 +790,30 @@ async function seed() {
|
||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
|
||||
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
|
||||
// Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials.
|
||||
// Must run AFTER random staff are created so upserts land correctly.
|
||||
await seedUatStaffAccounts(db);
|
||||
// ── 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 ──
|
||||
// Upsert services using name as unique key. With deterministic IDs in
|
||||
@@ -1009,7 +906,6 @@ async function seed() {
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
// ~30% of random-pool pets have alerts — lands squarely in the 25–35% AC band
|
||||
if (rand() < 0.3) {
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
@@ -1106,14 +1002,7 @@ async function seed() {
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
// ~30% of pets get alerts; TestCooper/TestRocky get deterministic types
|
||||
if (rand() < 0.3) {
|
||||
if (uc.petName === "TestCooper") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
if (uc.petName === "TestRocky") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
@@ -1136,14 +1025,7 @@ async function seed() {
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
// ~30% of pets get alerts; TestCooper/TestRocky get deterministic types
|
||||
if (rand() < 0.3) {
|
||||
if (uc.petName === "TestCooper") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
if (uc.petName === "TestRocky") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const CLIENT_EMAIL = "alice@example.com";
|
||||
const CLIENT_NAME = "Alice Smith";
|
||||
|
||||
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
|
||||
const UAT_CUSTOMER_EMAIL = "uat-customer@groombook.dev";
|
||||
const UAT_CUSTOMER_NAME = "UAT Customer";
|
||||
|
||||
const BETTER_AUTH_SESSION = {
|
||||
user: {
|
||||
id: "auth-user-001",
|
||||
@@ -167,33 +163,6 @@ describe("POST /portal/session-from-auth", () => {
|
||||
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
|
||||
});
|
||||
|
||||
it("returns 201 for uat-customer SSO bridge with correct clientId and clientName", async () => {
|
||||
const uatAuthSession = {
|
||||
user: {
|
||||
id: "auth-user-uat-customer",
|
||||
email: UAT_CUSTOMER_EMAIL,
|
||||
name: UAT_CUSTOMER_NAME,
|
||||
},
|
||||
session: {
|
||||
id: "ba-session-uat-customer",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
};
|
||||
mockGetSession.mockResolvedValue(uatAuthSession);
|
||||
mockClientRow = { id: UAT_CUSTOMER_ID, email: UAT_CUSTOMER_EMAIL, name: UAT_CUSTOMER_NAME };
|
||||
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
|
||||
const res = await app.request("/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("sessionId");
|
||||
expect(body.clientId).toBe(UAT_CUSTOMER_ID);
|
||||
expect(body.clientName).toBe(UAT_CUSTOMER_NAME);
|
||||
expect(insertedSession).not.toBeNull();
|
||||
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
|
||||
});
|
||||
|
||||
it("returns 503 when auth is not configured", async () => {
|
||||
mockGetAuth.mockImplementation(() => {
|
||||
throw new Error("Auth not initialized");
|
||||
|
||||
+1
-113
@@ -1,19 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
getDb,
|
||||
or,
|
||||
pets,
|
||||
appointments,
|
||||
staff,
|
||||
services,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
@@ -109,106 +97,6 @@ petsRouter.get("/:id", async (c) => {
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Fetch the pet
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Groomer RBAC: check appointment linkage to this pet's client
|
||||
if (isGroomer) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, pet.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Recent grooming history — last 10 completed appointments
|
||||
const recentHistory = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
notes: appointments.notes,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")))
|
||||
.orderBy(desc(appointments.startTime))
|
||||
.limit(10);
|
||||
|
||||
// Visit count (completed appointments)
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appointments)
|
||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
||||
const visitCount = countRow?.count ?? 0;
|
||||
|
||||
// Upcoming appointment (next scheduled or confirmed)
|
||||
const [upcoming] = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
notes: appointments.notes,
|
||||
confirmationStatus: appointments.confirmationStatus,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed"))
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime)
|
||||
.limit(1);
|
||||
|
||||
return c.json({
|
||||
id: pet.id,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
coatType: pet.coatType,
|
||||
petSizeCategory: pet.petSizeCategory,
|
||||
weightKg: pet.weightKg,
|
||||
dateOfBirth: pet.dateOfBirth,
|
||||
recentGroomingHistory: recentHistory.map((h) => ({
|
||||
id: h.id,
|
||||
startTime: h.startTime,
|
||||
notes: h.notes,
|
||||
serviceName: h.serviceName,
|
||||
staffName: h.staffName,
|
||||
})),
|
||||
visitCount,
|
||||
upcomingAppointment: upcoming
|
||||
? {
|
||||
id: upcoming.id,
|
||||
startTime: upcoming.startTime,
|
||||
notes: upcoming.notes,
|
||||
confirmationStatus: upcoming.confirmationStatus,
|
||||
serviceName: upcoming.serviceName,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
|
||||
Reference in New Issue
Block a user