Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a0dd5ed2a | |||
| bf064b3ada | |||
| 4df7d96020 | |||
| aee82efbac | |||
| 4cc0676d52 | |||
| 543d9560ec |
@@ -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
|
||||
|
||||
@@ -50,4 +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 prepare pnpm@9.15.4 --activate
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user