Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f007ecac72 | |||
| 53677b1420 | |||
| 0a3eb8a282 | |||
| b5f964c1ff | |||
| 86a6e3245c |
+17
-4
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user