Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a14bb5e17d | |||
| 280c699d0d | |||
| 5d6bc06295 | |||
| 53677b1420 | |||
| 0a3eb8a282 | |||
| b5f964c1ff | |||
| 86a6e3245c | |||
| aee82efbac | |||
| 4cc0676d52 | |||
| dff0e17a63 | |||
| 612c0467a1 | |||
| 543d9560ec | |||
| 17b44e3b00 | |||
| 45b3d4343d | |||
| 32156e9a45 | |||
| ed3d7df1c9 | |||
| 385ed10211 | |||
| 8e8a87767c | |||
| 2f17b1ab85 | |||
| 2a0b3cf3d3 | |||
| 78762b5278 | |||
| aa9670d4dc | |||
| e5f16a5fe5 | |||
| baeff6c4f5 | |||
| 8d9a9d8dba | |||
| 2380698128 | |||
| 00c6a36021 | |||
| f4561b539f | |||
| d847343090 | |||
| 190c39f905 | |||
| 122d32d635 | |||
| d458f93600 | |||
| 634e9d03e1 | |||
| 974dade8f7 | |||
| 3eaefb4911 | |||
| ff6f8471d5 | |||
| 6045024150 | |||
| df5e413930 | |||
| 7cb5fda3e3 | |||
| 76540cea0d | |||
| d83210e7e2 | |||
| 5c9cac7a28 | |||
| fad99dc032 | |||
| 247570abc8 | |||
| 4f5ec60961 | |||
| 39ffdccac7 | |||
| 1ff0d4230c | |||
| be5e9d8fc7 |
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, uat]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, uat]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -32,7 +32,9 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm --filter @groombook/api typecheck
|
||||
run: |
|
||||
pnpm --filter @groombook/api typecheck
|
||||
pnpm --filter @groombook/db typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm --filter @groombook/api lint
|
||||
|
||||
+9
-3
@@ -1,5 +1,7 @@
|
||||
FROM node:22-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
RUN corepack enable && corepack install -g pnpm@9.15.4
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV COREPACK_ENABLE_STRICT=0
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps
|
||||
@@ -11,7 +13,6 @@ 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 ./
|
||||
@@ -21,7 +22,9 @@ RUN pnpm --filter @groombook/types build && \
|
||||
|
||||
# Runtime
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
RUN corepack enable && corepack install -g pnpm@9.15.4
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV COREPACK_ENABLE_STRICT=0
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -50,4 +53,7 @@ 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
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||
|
||||
@@ -103,6 +103,18 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) |
|
||||
| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) |
|
||||
|
||||
#### Seed Data Verification (GRO-1898)
|
||||
|
||||
> As of PR #98, UAT seed data populates all 5 extended profile fields for every pet, including the 5 deterministic UAT test client pets (Alpha, Bravo, Charlie, Delta, Echo). This enables manual verification of extended profile rendering without requiring a DB reset.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-3.20 | GET /api/clients returns seed data | GET /api/clients | 200 OK, array with 1+ clients (UAT seed creates 500 + 5 deterministic UAT clients) |
|
||||
| TC-API-3.21 | GET /api/pets/{id} returns extended fields for seed pet | Pick any pet ID from UAT test clients (uat-alpha through uat-echo pet names: TestBuddy, TestMax, TestCooper, TestRocky, TestDuke) and GET /api/pets/{id} | 200 OK; coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts all non-null |
|
||||
| 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" |
|
||||
|
||||
### 4.4 Appointment Scheduling
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|
||||
@@ -178,6 +178,9 @@ vi.mock("../db/index.js", () => {
|
||||
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
|
||||
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
|
||||
|
||||
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
|
||||
let selectedColumns: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
function makeChainable(rows: unknown[]) {
|
||||
const arr = rows as unknown[];
|
||||
return new Proxy(arr, {
|
||||
@@ -188,25 +191,67 @@ vi.mock("../db/index.js", () => {
|
||||
if (prop === Symbol.iterator) {
|
||||
return function* () { for (const v of target) yield v; };
|
||||
}
|
||||
if (prop === Symbol.asyncIterator) {
|
||||
return async function* () { for (const v of target) yield v; };
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// sql mock: returns an object with .as() so drizzle's select() can alias it
|
||||
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
|
||||
const queryString = _strings[0];
|
||||
const asFn = (alias: string) => ({
|
||||
sql: { queryChunks: [_strings[0]] },
|
||||
fieldAlias: alias,
|
||||
getSQL() { return this.sql; },
|
||||
});
|
||||
return { queryChunks: [queryString], as: asFn };
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => {
|
||||
const name = (table as { _name?: string })._name;
|
||||
if (name === "pets") return makeChainable(mock.pets);
|
||||
if (name === "appointments") return makeChainable(mock.appointments);
|
||||
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
||||
if (name === "staff") return makeChainable(mock.staffMembers);
|
||||
if (name === "services") return makeChainable(mock.services);
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
select: (cols?: Record<string, unknown>) => {
|
||||
selectedColumns = {};
|
||||
if (cols) {
|
||||
// Inspect cols to find sql-aliased expressions and their aliases
|
||||
for (const [alias, expr] of Object.entries(cols)) {
|
||||
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
|
||||
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
|
||||
// Detect count(*) queries
|
||||
if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record<string, unknown>) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) {
|
||||
// Store count query intent — we'll resolve it in from()
|
||||
if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {};
|
||||
selectedColumns["appointments"][alias] = { _isCountQuery: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: (table: unknown) => {
|
||||
const name = (table as { _name?: string })._name;
|
||||
const tableCols = selectedColumns[name] || {};
|
||||
// If this table has a count query, return computed count result
|
||||
const countQueryEntry = Object.entries(tableCols).find(([, v]) =>
|
||||
typeof v === "object" && v !== null && "_isCountQuery" in v
|
||||
);
|
||||
if (countQueryEntry) {
|
||||
const [countAlias] = countQueryEntry;
|
||||
const count = (name === "appointments" ? mock.appointments : [])
|
||||
.filter((row: Record<string, unknown>) => row.status === "completed").length;
|
||||
return makeChainable([{ [countAlias]: count }]);
|
||||
}
|
||||
if (name === "pets") return makeChainable(mock.pets);
|
||||
if (name === "appointments") return makeChainable(mock.appointments);
|
||||
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
||||
if (name === "staff") return makeChainable(mock.staffMembers);
|
||||
if (name === "services") return makeChainable(mock.services);
|
||||
return makeChainable([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
|
||||
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
||||
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
||||
@@ -222,7 +267,7 @@ vi.mock("../db/index.js", () => {
|
||||
exists: vi.fn(() => true),
|
||||
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
|
||||
or: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||
sql: vi.fn((str: string) => str),
|
||||
sql: sqlMock,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+73
-7
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
@@ -252,6 +253,38 @@ const appointmentNotes = [
|
||||
"Client running late, pushed start by 15min",
|
||||
];
|
||||
|
||||
const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9];
|
||||
|
||||
const temperamentFlags = [
|
||||
[], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"],
|
||||
["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"],
|
||||
["friendly", "nippy"], ["anxious", "territorial"],
|
||||
];
|
||||
|
||||
const medicalAlertsList = [
|
||||
[] as MedicalAlert[],
|
||||
[] as MedicalAlert[],
|
||||
[{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }],
|
||||
[{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }],
|
||||
];
|
||||
|
||||
const preferredCutsList = [
|
||||
[], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"],
|
||||
["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"],
|
||||
["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"],
|
||||
["Breed Standard", "Sanitary Trim"], ["Summer Shave"],
|
||||
["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"],
|
||||
];
|
||||
|
||||
const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"];
|
||||
|
||||
const visitLogNotes = [
|
||||
null, null,
|
||||
"Coat in great condition",
|
||||
@@ -872,6 +905,11 @@ async function seed() {
|
||||
cutStyle: pick(cutStyles),
|
||||
shampooPreference: pick(shampoos),
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
coatType: pick(coatTypes),
|
||||
temperamentScore: pick(temperamentScores),
|
||||
temperamentFlags: pick(temperamentFlags),
|
||||
medicalAlerts: pick(medicalAlertsList),
|
||||
preferredCuts: pick(preferredCutsList),
|
||||
customFields: {},
|
||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||
});
|
||||
@@ -907,6 +945,11 @@ async function seed() {
|
||||
cutStyle: pet.cutStyle,
|
||||
shampooPreference: pet.shampooPreference,
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
coatType: pet.coatType,
|
||||
temperamentScore: pet.temperamentScore,
|
||||
temperamentFlags: pet.temperamentFlags,
|
||||
medicalAlerts: pet.medicalAlerts,
|
||||
preferredCuts: pet.preferredCuts,
|
||||
customFields: pet.customFields,
|
||||
image: pet.image,
|
||||
},
|
||||
@@ -929,13 +972,18 @@ async function seed() {
|
||||
petId: string;
|
||||
petName: string;
|
||||
petBreed: string;
|
||||
petCoatType: string;
|
||||
petTemperamentScore: number;
|
||||
petTemperamentFlags: string[];
|
||||
petMedicalAlerts: MedicalAlert[];
|
||||
petPreferredCuts: string[];
|
||||
}
|
||||
const uatClients: UatClient[] = [
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" },
|
||||
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog" },
|
||||
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle" },
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] },
|
||||
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] },
|
||||
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] },
|
||||
];
|
||||
|
||||
for (const uc of uatClients) {
|
||||
@@ -943,8 +991,26 @@ async function seed() {
|
||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||
await db.insert(schema.pets)
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||
.values({
|
||||
id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
|
||||
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
coatType: uc.petCoatType,
|
||||
temperamentScore: uc.petTemperamentScore,
|
||||
temperamentFlags: uc.petTemperamentFlags,
|
||||
medicalAlerts: uc.petMedicalAlerts,
|
||||
preferredCuts: uc.petPreferredCuts,
|
||||
image: pick(demoPetImages),
|
||||
})
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: {
|
||||
clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
|
||||
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
coatType: uc.petCoatType,
|
||||
temperamentScore: uc.petTemperamentScore,
|
||||
temperamentFlags: uc.petTemperamentFlags,
|
||||
medicalAlerts: uc.petMedicalAlerts,
|
||||
preferredCuts: uc.petPreferredCuts,
|
||||
image: pick(demoPetImages),
|
||||
} });
|
||||
// Create one completed appointment for this client
|
||||
const apptId = uuid();
|
||||
const svcIdx = 0;
|
||||
|
||||
+171
-2
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
import type { MedicalAlert } from "@groombook/types";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
@@ -243,6 +244,55 @@ const groomingNotes = [
|
||||
"Previous clipper burn — be gentle on belly",
|
||||
];
|
||||
|
||||
// ── Extended pet profile pools ─────────────────────────────────────────────────
|
||||
|
||||
const temperamentFlagPool: string[] = [
|
||||
"friendly",
|
||||
"anxious-with-strangers",
|
||||
"good-with-kids",
|
||||
"leash-reactive",
|
||||
"vocal",
|
||||
"high-energy",
|
||||
"calm-on-table",
|
||||
"treat-motivated",
|
||||
];
|
||||
|
||||
const medicalAlertPool: MedicalAlert[] = [
|
||||
{ id: "", type: "allergies", description: "Seasonal allergies — monitor skin", severity: "low" },
|
||||
{ id: "", type: "allergies", description: "Chicken allergy — avoid poultry-based treats", severity: "high" },
|
||||
{ id: "", type: "joint", description: "Hip dysplasia — handle with care", severity: "medium" },
|
||||
{ id: "", type: "joint", description: "Arthritis — anti-inflammatory medication on file", severity: "medium" },
|
||||
{ id: "", type: "dental", description: "Dental disease — extractions in history", severity: "medium" },
|
||||
{ id: "", type: "dental", description: "Baby teeth retained — vet monitor", severity: "low" },
|
||||
{ id: "", type: "heart", description: "Heart murmur grade II — avoid stress", severity: "high" },
|
||||
{ id: "", type: "heart", description: "Murmur cleared by vet last year", severity: "low" },
|
||||
{ id: "", type: "other", description: "Eye ulcer history — be careful around face", severity: "medium" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
const preferredCutPool: string[] = [
|
||||
"Puppy Cut",
|
||||
"Teddy Bear Cut",
|
||||
"Lion Cut",
|
||||
"Breed Standard",
|
||||
"Summer Shave",
|
||||
"Kennel Cut",
|
||||
"Lamb Cut",
|
||||
"Continental Clip",
|
||||
"Sporting Clip",
|
||||
"Sanitary Trim",
|
||||
"Face & Feet Trim",
|
||||
"Full Groom",
|
||||
];
|
||||
|
||||
type CoatType = (typeof schema.coatTypeEnum.enumValues)[number];
|
||||
type PetSizeCategory = (typeof schema.petSizeCategoryEnum.enumValues)[number];
|
||||
|
||||
const coatTypePool: CoatType[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"];
|
||||
const petSizeCategoryPool: PetSizeCategory[] = ["small", "medium", "large", "extra_large"];
|
||||
|
||||
const appointmentNotes = [
|
||||
null, null, null, null,
|
||||
"Client requested extra brushing",
|
||||
@@ -574,6 +624,63 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
console.log(`✓ UAT Pet '${existing.name}' already exists — skipping`);
|
||||
} 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,
|
||||
});
|
||||
console.log(`✓ Created UAT pet '${pet.name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
@@ -853,6 +960,18 @@ async function seed() {
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
customFields: {},
|
||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
if (rand() < 0.3) {
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
return [];
|
||||
})(),
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
});
|
||||
|
||||
petRecords.push({ id: petId, clientId });
|
||||
@@ -888,6 +1007,12 @@ async function seed() {
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
customFields: pet.customFields,
|
||||
image: pet.image,
|
||||
temperamentScore: pet.temperamentScore,
|
||||
temperamentFlags: pet.temperamentFlags,
|
||||
medicalAlerts: pet.medicalAlerts,
|
||||
preferredCuts: pet.preferredCuts,
|
||||
coatType: pet.coatType,
|
||||
petSizeCategory: pet.petSizeCategory,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -922,8 +1047,52 @@ async function seed() {
|
||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||
await db.insert(schema.pets)
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||
.values({
|
||||
id: uc.petId,
|
||||
clientId: uc.id,
|
||||
name: uc.petName,
|
||||
species: "Dog",
|
||||
breed: uc.petBreed,
|
||||
weightKg: "25.00",
|
||||
dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
image: pick(demoPetImages),
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
if (rand() < 0.3) {
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
return [];
|
||||
})(),
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.pets.id,
|
||||
set: {
|
||||
clientId: uc.id,
|
||||
name: uc.petName,
|
||||
species: "Dog",
|
||||
breed: uc.petBreed,
|
||||
weightKg: "25.00",
|
||||
dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
image: pick(demoPetImages),
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
if (rand() < 0.3) {
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
return [];
|
||||
})(),
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
},
|
||||
});
|
||||
// Create one completed appointment for this client
|
||||
const apptId = uuid();
|
||||
const svcIdx = 0;
|
||||
|
||||
Generated
+13
@@ -970,66 +970,79 @@ packages:
|
||||
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
|
||||
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.3':
|
||||
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
|
||||
|
||||
@@ -6,6 +6,10 @@ 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",
|
||||
@@ -163,6 +167,33 @@ 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");
|
||||
|
||||
+113
-1
@@ -1,7 +1,19 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
getDb,
|
||||
or,
|
||||
pets,
|
||||
appointments,
|
||||
staff,
|
||||
services,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
@@ -97,6 +109,106 @@ 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