Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ec9e9a8fd | |||
| e9aef5719f | |||
| c588c94dcb | |||
| e00cdc1321 | |||
| 1891b9c523 | |||
| 0ab16b82e0 | |||
| 981a257d2d | |||
| a14bb5e17d |
@@ -118,6 +118,17 @@ jobs:
|
|||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
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
|
- name: Build and push Seed image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 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,6 +246,13 @@
|
|||||||
"when": 1751140800000,
|
"when": 1751140800000,
|
||||||
"tag": "0034_extend_pet_profile_columns",
|
"tag": "0034_extend_pet_profile_columns",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1751140800000,
|
||||||
|
"tag": "0035_add_short_to_coat_type_enum",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+104
-93
@@ -270,6 +270,10 @@ const medicalAlertPool: MedicalAlert[] = [
|
|||||||
{ id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" },
|
{ 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: "Luxating patella — short walks only", severity: "medium" },
|
||||||
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
|
{ 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[] = [
|
const preferredCutPool: string[] = [
|
||||||
@@ -385,78 +389,19 @@ 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 },
|
{ id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
// ── UAT staff account seeding (shared between seed paths) ─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeds only the minimal known users for prod/demo environments.
|
* Seeds or upserts the deterministic UAT staff accounts with numeric OIDC subs
|
||||||
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
|
* from SEED_UAT_*_OIDC_SUB / SEED_UAT_GROOMER_OIDC_SUBS env vars.
|
||||||
* Idempotent: skips creation if records already exist.
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
async function seedKnownUsers() {
|
async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
|
||||||
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) ──
|
// ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ──
|
||||||
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
|
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
|
||||||
if (uatSuperOidcSub) {
|
if (uatSuperOidcSub) {
|
||||||
@@ -680,6 +625,84 @@ async function seedKnownUsers() {
|
|||||||
console.log(`✓ Created UAT pet '${pet.name}'`);
|
console.log(`✓ Created UAT pet '${pet.name}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
@@ -847,30 +870,10 @@ async function seed() {
|
|||||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
// Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials.
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
// Must run AFTER random staff are created so upserts land correctly.
|
||||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
await seedUatStaffAccounts(db);
|
||||||
for (let i = 0; i < groomerCount; i++) {
|
|
||||||
const email = groomerEmails[i]!;
|
|
||||||
const name = groomerNames[i]!;
|
|
||||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
|
||||||
await db.insert(schema.staff)
|
|
||||||
.values({
|
|
||||||
id: staffId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
oidcSub: email,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.staff.email,
|
|
||||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
|
||||||
});
|
|
||||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services ──
|
// ── Services ──
|
||||||
// Upsert services using name as unique key. With deterministic IDs in
|
// Upsert services using name as unique key. With deterministic IDs in
|
||||||
@@ -1059,6 +1062,14 @@ async function seed() {
|
|||||||
temperamentScore: randInt(1, 5),
|
temperamentScore: randInt(1, 5),
|
||||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||||
medicalAlerts: (() => {
|
medicalAlerts: (() => {
|
||||||
|
// Deterministic alerts for UAT AC pets
|
||||||
|
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() }));
|
||||||
|
}
|
||||||
|
// Other UAT pets: random
|
||||||
if (rand() < 0.3) {
|
if (rand() < 0.3) {
|
||||||
const count = rand() < 0.7 ? 1 : 2;
|
const count = rand() < 0.7 ? 1 : 2;
|
||||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||||
|
|||||||
+6
-5
@@ -12,6 +12,7 @@ import {
|
|||||||
appointments,
|
appointments,
|
||||||
staff,
|
staff,
|
||||||
services,
|
services,
|
||||||
|
sql,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
@@ -153,11 +154,11 @@ petsRouter.get("/:id/profile-summary", async (c) => {
|
|||||||
.limit(10);
|
.limit(10);
|
||||||
|
|
||||||
// Visit count (completed appointments)
|
// Visit count (completed appointments)
|
||||||
const [{ count }] = await db
|
const [countRow] = await db
|
||||||
.select({ count: appointments.id })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")))
|
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
||||||
.limit(1);
|
const visitCount = countRow?.count ?? 0;
|
||||||
|
|
||||||
// Upcoming appointment (next scheduled or confirmed)
|
// Upcoming appointment (next scheduled or confirmed)
|
||||||
const [upcoming] = await db
|
const [upcoming] = await db
|
||||||
@@ -195,7 +196,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
|
|||||||
serviceName: h.serviceName,
|
serviceName: h.serviceName,
|
||||||
staffName: h.staffName,
|
staffName: h.staffName,
|
||||||
})),
|
})),
|
||||||
visitCount: Number(count ?? 0),
|
visitCount,
|
||||||
upcomingAppointment: upcoming
|
upcomingAppointment: upcoming
|
||||||
? {
|
? {
|
||||||
id: upcoming.id,
|
id: upcoming.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user