Compare commits

...

5 Commits

Author SHA1 Message Date
Flea Flicker f007ecac72 GRO-1939: Add CI smoke test for blackholed migrate runtime
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Failing after 42s
2026-05-30 04:24:04 +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
5 changed files with 197 additions and 23 deletions
+17 -4
View File
@@ -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
@@ -98,7 +100,7 @@ jobs:
push: true
tags: |
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombok/api:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
@@ -116,6 +118,17 @@ jobs:
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
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
uses: docker/build-push-action@v6
with:
@@ -141,5 +154,5 @@ jobs:
tags: |
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
+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"]
@@ -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,
};
});
+2 -2
View File
@@ -287,8 +287,8 @@ const preferredCutPool: string[] = [
"Full Groom",
];
type CoatType = schema.coatTypeEnum.enumValues[number];
type PetSizeCategory = schema.petSizeCategoryEnum.enumValues[number];
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"];
+112 -1
View File
@@ -1,7 +1,18 @@
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,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -97,6 +108,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 [{ count }] = await db
.select({ count: appointments.id })
.from(appointments)
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")))
.limit(1);
// 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: Number(count ?? 0),
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");