Compare commits

..

8 Commits

Author SHA1 Message Date
Paperclip 03f1dd3bfa fix(db): add missing 'short' value to coat_type enum (GRO-1953)
CI / Test (pull_request) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Images (pull_request) Successful in 1m6s
The seed file coatTypePool includes 'short', but migration 0031
created the coat_type enum without it. The ALTER TYPE ... ADD VALUE
statement makes the migration idempotent so it is safe to re-run.
2026-05-30 04:19:30 +00:00
Flea Flicker a14bb5e17d fix(api): repair root src/routes/pets.ts visit-count query (GRO-1945)
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
Use sql\`count(*)::int\` instead of selecting appointments.id, which was
causing TS2339 under noUncheckedIndexedAccess (arr[0] is T | undefined).

Import sql from @groombook/db. Use countRow?.count ?? 0 to stay
noUncheckedIndexedAccess-safe.

Matches the working implementation in apps/api/src/routes/pets.ts:365.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-30 03:21:28 +00:00
Flea Flicker 280c699d0d fix(seed): add uat-customer client record for SSO bridge UAT (GRO-1935) (#104)
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Test (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Failing after 37s
CI / Lint & Typecheck (push) Successful in 14s
CI / Test (push) Successful in 2m19s
CI / Build & Push Docker Images (push) Failing after 33s
2026-05-30 03:10:48 +00:00
The Dogfather 5d6bc06295 Merge pull request 'fix(docker): bake pnpm into image to avoid runtime corepack downloads (GRO-1909)' (#101) from fix/GRO-1909-migrate-corepack-offline into dev
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (push) Failing after 37s
CI / Build & Push Docker Images (pull_request) Failing after 36s
2026-05-30 03:05:10 +00:00
Flea Flicker 53677b1420 feat(pets): add GET /:id/profile-summary endpoint
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Failing after 36s
Adds profile-summary endpoint for groombook web to display:
- Basic pet fields (name, species, breed, coatType, etc.)
- Recent grooming history (last 10 completed appointments with staff names)
- Visit count (completed appointments)
- Upcoming appointment (next scheduled/confirmed)

Groomer RBAC: groomers can only see pets they've had appointments with.
Non-groomer staff (admin/super) can see all pets.

Fixes GRO-1802 (UAT regression: profile-summary route never deployed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:00:39 +00:00
Flea Flicker 0a3eb8a282 fix(docker): bake pnpm into image to avoid runtime corepack downloads (GRO-1909)
Use `corepack install -g` instead of `corepack prepare --activate` to write
pnpm to a stable global path (/usr/local/bin/pnpm) rather than relying on
corepack shims that re-validate against npmjs.org at runtime.

Set COREPACK_ENABLE_DOWNLOAD_PROMPT=0 and COREPACK_ENABLE_STRICT=0 to suppress
the interactive download prompt and strict version checks that also trigger
network access.

Remove the dead `RUN mkdir -p /home/node/.cache/node/corepack` line from the
builder stage (vestigial cache-location configuration).

Fixes: GRO-1916 (prod migrate-schema EAI_AGAIN on registry.npmjs.org)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:00:39 +00:00
Flea Flicker b5f964c1ff fix(test): mock db to handle sql count(*) queries (GRO-1917) (#102)
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 37s
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (pull_request) Successful in 36s
2026-05-29 16:36:40 +00:00
Flea Flicker 86a6e3245c fix(seed): use typeof on enum.enumValues for db build (#100)
CI / Lint & Typecheck (push) Successful in 21s
CI / Test (push) Failing after 13m48s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-29 15:40:51 +00:00
6 changed files with 231 additions and 5 deletions
+9 -4
View File
@@ -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,5 +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 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
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
@@ -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,
"tag": "0034_extend_pet_profile_columns",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1751140800000,
"tag": "0035_add_short_to_coat_type_enum",
"breakpoints": true
}
]
}
+57
View File
@@ -624,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.
@@ -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
View File
@@ -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");