Compare commits

..

3 Commits

Author SHA1 Message Date
Flea Flicker 0e64f9d3ae fix(gro-1746): apply UAT seed data to root src/routes/admin/seed.ts
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Build & Push Docker Images (pull_request) Failing after 28s
- Add UAT_CLIENT (name: "UAT Customer", email: uat-customer@groombook.dev,
  phone: 555-0100, address: 1 UAT Lane, status: active)
- Add UAT_PETS: Bella (Poodle, curly) and Max (Labrador Retriever, short)
- Add idempotent UAT Client upsert block and UAT Pets loop after Demo Dog
- Change adminSeedRouter.post("/seed") → adminSeedRouter.post("/") so the
  mounted path is /api/admin/seed (not /api/admin/seed/seed)

Fixes the build-path mismatch where the Dockerfile's COPY src/ src/ would
not include the UAT data changes that were only in apps/api/src/.

Issue: GRO-1746
Fixes: GRO-1743
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 18:35:26 +00:00
Flea Flicker b3b2113daa fix(db): add missing extended pet profile fields to buildPet factory
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 9s
CI / Build & Push Docker Images (pull_request) Successful in 47s
Lint Roller (QA) flagged that buildPet in factories.ts was missing the
4 fields added to the pets table schema, causing TS2739 in the Docker
build job (run 1701, job 3717).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:31:52 +00:00
Flea Flicker 8c184dddbd fix(schema): add missing extended pet profile fields to packages/db
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Failing after 24s
Add temperamentScore, temperamentFlags, medicalAlerts, and preferredCuts
to the pets table in packages/db/src/schema.ts to match the API schema.

These columns already exist in the database via migration
0030_extended_pet_profile.sql (confirmed by GRO-1748).

Refs: GRO-1752

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:19:25 +00:00
11 changed files with 81 additions and 123 deletions
-4
View File
@@ -95,7 +95,6 @@ jobs:
file: Dockerfile
target: runner
push: true
provenance: false
tags: |
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
@@ -109,7 +108,6 @@ jobs:
file: Dockerfile
target: migrate
push: true
provenance: false
tags: |
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
@@ -123,7 +121,6 @@ jobs:
file: Dockerfile
target: seed
push: true
provenance: false
tags: |
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
@@ -137,7 +134,6 @@ jobs:
file: Dockerfile
target: reset
push: true
provenance: false
tags: |
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
-2
View File
@@ -41,8 +41,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
> **Note (GRO-1977):** Seed credential provisioning is idempotent — re-running the seed with updated `SEED_UAT_*_PASSWORD` env vars rotates stored credential hashes. TC-API-1.4 through TC-API-1.7 now return 200 for all 4 UAT personas (previously returned 401 due to frozen-hash bug).
| TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved |
| TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true |
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
@@ -67,7 +67,6 @@ let dbAccounts: AccountRow[] = [];
let dbStaff: StaffRow[] = [];
let insertedUsers: UserRow[] = [];
let insertedAccounts: AccountRow[] = [];
let updatedAccounts: Array<{ id: string; password: string }> = [];
let updatedStaff: Array<{ id: string; userId: string }> = [];
const originalEnv = { ...process.env };
@@ -78,7 +77,6 @@ function resetMock() {
dbStaff = [];
insertedUsers = [];
insertedAccounts = [];
updatedAccounts = [];
updatedStaff = [];
process.env = { ...originalEnv };
}
@@ -175,11 +173,7 @@ async function seedUatCredentials(
);
if (existingAccount) {
// Idempotent update: re-hash the current env password and update the stored hash.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
existingAccount.password = passwordHash;
updatedAccounts.push({ id: existingAccount.id, password: passwordHash });
// skip — already has credential account
} else {
// Use Better-Auth's hashPassword so test helper matches production seed.ts
const { hashPassword } = await import("better-auth/crypto");
@@ -318,9 +312,9 @@ describe("seedUatCredentials — credential provisioning logic", () => {
expect(updatedStaff).toHaveLength(0);
});
// ── AC-5: idempotent — does not insert duplicate records ───────────────────
// ── AC-5: idempotent — skips when user already exists ───────────────────────
it("AC-5: re-running does not insert duplicate user or account records", async () => {
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
const preExistingUsers: UserRow[] = [
@@ -336,53 +330,25 @@ describe("seedUatCredentials — credential provisioning logic", () => {
},
];
// First call — nothing inserted (user + account pre-exist)
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No inserts — user and account already exist
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
});
// ── AC-5b: password rotation on re-seed ─────────────────────────────────────
it("AC-5b: re-running with a new password updates the stored credential hash", async () => {
const OLD_PASSWORD = "old-password-abc";
const NEW_PASSWORD = "new-password-xyz";
process.env.SEED_UAT_CUSTOMER_PASSWORD = NEW_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: await hashPassword(OLD_PASSWORD),
},
];
// Second call — still nothing inserted
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No new records inserted
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Password WAS updated to the new env value
expect(updatedAccounts).toHaveLength(1);
expect(updatedAccounts[0]!.id).toBe("pre-existing-acct");
// New hash is valid Better-Auth format (salt:key, each hex)
const newHashParts = updatedAccounts[0]!.password.split(":");
expect(Buffer.from(newHashParts[0]!, "hex")).toHaveLength(16);
expect(Buffer.from(newHashParts[1]!, "hex")).toHaveLength(64);
});
// ── AC-6: missing env var skips with warning ────────────────────────────────
+1 -8
View File
@@ -561,14 +561,7 @@ async function seedKnownUsers() {
.limit(1);
if (existingAccount) {
// Idempotent: re-hash the current env password and update the stored hash.
// This ensures re-running the seed with a new SEED_UAT_*_PASSWORD rotates the credential.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
await db.update(schema.account)
.set({ password: passwordHash })
.where(eq(schema.account.id, existingAccount.id));
console.log(`✓ Updated credential account password for '${acct.email}'`);
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
} else {
// Use Better-Auth's own hashPassword to guarantee parameter/encoding match.
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random
-55
View File
@@ -36,18 +36,6 @@ const DEMO_PET = {
weightKg: "30.00",
};
const UAT_CLIENT = {
name: "UAT Customer",
email: "uat-customer@groombook.dev",
phone: "555-0100",
status: "active" as const,
};
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short" as const },
];
const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
@@ -140,49 +128,6 @@ adminSeedRouter.post("/seed", async (c) => {
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
}
// ── Client: UAT Customer ──────────────────────────────────────────────────
const [existingUatClient] = await db
.select()
.from(clients)
.where(eq(clients.email, UAT_CLIENT.email));
let uatClientId: string;
if (existingUatClient) {
uatClientId = existingUatClient.id;
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
} else {
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
uatClientId = created!.id;
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
}
// ── Pets: UAT Customer Pets ───────────────────────────────────────────────
const existingUatPets = await db
.select()
.from(pets)
.where(eq(pets.clientId, uatClientId));
for (const uatPet of UAT_PETS) {
const existingPet = existingUatPets.find(
(p) => p.name === uatPet.name && p.species === uatPet.species
);
if (existingPet) {
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`);
} else {
const [created] = await db
.insert(pets)
.values({
clientId: uatClientId,
name: uatPet.name,
species: uatPet.species,
breed: uatPet.breed,
coatType: uatPet.coatType,
})
.returning();
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
}
}
return c.json({
message: "Seed complete",
details: results,
+4
View File
@@ -105,6 +105,10 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
+1 -1
View File
@@ -12,7 +12,7 @@ export function getDb() {
if (_db) return _db;
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
const client = postgres(url, { max: 10, connect_timeout: 5 });
const client = postgres(url, { max: 10 });
_db = drizzle(client, { schema });
return _db;
}
+5
View File
@@ -11,6 +11,7 @@ import {
unique,
uuid,
} from "drizzle-orm/pg-core";
import type { MedicalAlert } from "@groombook/types";
// ─── Enums ────────────────────────────────────────────────────────────────────
@@ -164,6 +165,10 @@ export const pets = pgTable(
specialCareNotes: text("special_care_notes"),
coatType: coatTypeEnum("coat_type"),
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
temperamentScore: integer("temperament_score"),
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
+4 -9
View File
@@ -59,9 +59,6 @@ app.use(
);
// Health check — no auth required, registered on app at full path before auth middleware
// /health: used by Dockerfile HEALTHCHECK and K8s readinessProbe/livenessProbe (port 3000 direct)
app.get("/health", (c) => c.json({ status: "ok" }));
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
app.get("/api/health", (c) => c.json({ status: "ok" }));
// Public booking routes — no auth required, must be registered before auth middleware
@@ -285,16 +282,14 @@ startReminderScheduler();
function shutdown() {
console.log("Shutting down gracefully...");
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
// If graceful close takes >8s, force-exit to avoid being killed undrained
setTimeout(() => {
console.error("Graceful close timeout — forcing exit");
process.exit(1);
}, 8_000);
server.close(() => {
console.log("HTTP server closed");
process.exit(0);
});
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", shutdown);
+1 -3
View File
@@ -186,9 +186,7 @@ export async function initAuth(): Promise<void> {
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr, {
signal: AbortSignal.timeout(5000),
});
const discoveryRes = await fetch(discoveryUrlStr);
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
+60 -2
View File
@@ -18,7 +18,7 @@ const KNOWN_STAFF = {
name: "Demo Manager",
email: "demo-manager@groombook.dev",
oidcSub: "demo-manager-001",
role: "manager" as const,
role: "manager,
active: true,
};
@@ -36,6 +36,19 @@ const DEMO_PET = {
weightKg: "30.00",
};
const UAT_CLIENT = {
name: "UAT Customer",
email: "uat-customer@groombook.dev",
phone: "555-0100",
address: "1 UAT Lane, Test City, CA 90210",
status: "active,
};
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short", weightKg: "30.00" },
];
const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
adminSeedRouter.post("/seed", async (c) => {
adminSeedRouter.post("/", async (c) => {
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
if (process.env.AUTH_DISABLED === "true") {
return c.json(
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
}
// ── Client: UAT Customer ──────────────────────────────────────────────────
const [existingUatClient] = await db
.select()
.from(clients)
.where(eq(clients.email, UAT_CLIENT.email));
let uatClientId: string;
if (existingUatClient) {
uatClientId = existingUatClient.id;
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
} else {
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
uatClientId = created!.id;
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
}
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
const existingUatPets = await db
.select()
.from(pets)
.where(eq(pets.clientId, uatClientId));
for (const uatPet of UAT_PETS) {
const existing = existingUatPets.find(
(p) => p.name === uatPet.name && p.species === uatPet.species
);
if (existing) {
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existing.id})`);
} else {
const [created] = await db
.insert(pets)
.values({
clientId: uatClientId,
name: uatPet.name,
species: uatPet.species,
breed: uatPet.breed,
coatType: uatPet.coatType as any,
weightKg: uatPet.weightKg,
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
})
.returning();
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
}
}
return c.json({
message: "Seed complete",
details: results,