Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f007ecac72 | |||
| 53677b1420 | |||
| 0a3eb8a282 | |||
| b5f964c1ff | |||
| 86a6e3245c | |||
| aee82efbac | |||
| 4cc0676d52 | |||
| 543d9560ec |
+17
-4
@@ -32,7 +32,9 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm --filter @groombook/api typecheck
|
run: |
|
||||||
|
pnpm --filter @groombook/api typecheck
|
||||||
|
pnpm --filter @groombook/db typecheck
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm --filter @groombook/api lint
|
run: pnpm --filter @groombook/api lint
|
||||||
@@ -98,7 +100,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
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-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
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-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
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
|
- name: Build and push Seed image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -141,5 +154,5 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
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-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||||
|
|||||||
+9
-3
@@ -1,5 +1,7 @@
|
|||||||
FROM node:22-alpine AS base
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install deps
|
# Install deps
|
||||||
@@ -11,7 +13,6 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
RUN mkdir -p /home/node/.cache/node/corepack
|
|
||||||
COPY packages/ packages/
|
COPY packages/ packages/
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
@@ -21,7 +22,9 @@ RUN pnpm --filter @groombook/types build && \
|
|||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
FROM node:22-alpine AS runner
|
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
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
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
|
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||||
FROM builder AS reset
|
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"]
|
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 staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
|
||||||
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
|
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[]) {
|
function makeChainable(rows: unknown[]) {
|
||||||
const arr = rows as unknown[];
|
const arr = rows as unknown[];
|
||||||
return new Proxy(arr, {
|
return new Proxy(arr, {
|
||||||
@@ -188,25 +191,67 @@ vi.mock("../db/index.js", () => {
|
|||||||
if (prop === Symbol.iterator) {
|
if (prop === Symbol.iterator) {
|
||||||
return function* () { for (const v of target) yield v; };
|
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
|
// @ts-expect-error proxy
|
||||||
return target[prop];
|
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 {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: (cols?: Record<string, unknown>) => {
|
||||||
from: (table: unknown) => {
|
selectedColumns = {};
|
||||||
const name = (table as { _name?: string })._name;
|
if (cols) {
|
||||||
if (name === "pets") return makeChainable(mock.pets);
|
// Inspect cols to find sql-aliased expressions and their aliases
|
||||||
if (name === "appointments") return makeChainable(mock.appointments);
|
for (const [alias, expr] of Object.entries(cols)) {
|
||||||
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
|
||||||
if (name === "staff") return makeChainable(mock.staffMembers);
|
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
|
||||||
if (name === "services") return makeChainable(mock.services);
|
// Detect count(*) queries
|
||||||
return makeChainable([]);
|
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: () => [{}] }) }),
|
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
|
||||||
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
||||||
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
||||||
@@ -222,7 +267,7 @@ vi.mock("../db/index.js", () => {
|
|||||||
exists: vi.fn(() => true),
|
exists: vi.fn(() => true),
|
||||||
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
|
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
|
||||||
or: vi.fn((a: unknown, b: unknown) => [a, b]),
|
or: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||||
sql: vi.fn((str: string) => str),
|
sql: sqlMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+114
-2
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
import type { MedicalAlert } from "@groombook/types";
|
||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -243,6 +244,55 @@ const groomingNotes = [
|
|||||||
"Previous clipper burn — be gentle on belly",
|
"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 = [
|
const appointmentNotes = [
|
||||||
null, null, null, null,
|
null, null, null, null,
|
||||||
"Client requested extra brushing",
|
"Client requested extra brushing",
|
||||||
@@ -853,6 +903,18 @@ async function seed() {
|
|||||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||||
customFields: {},
|
customFields: {},
|
||||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
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 });
|
petRecords.push({ id: petId, clientId });
|
||||||
@@ -888,6 +950,12 @@ async function seed() {
|
|||||||
specialCareNotes: pet.specialCareNotes,
|
specialCareNotes: pet.specialCareNotes,
|
||||||
customFields: pet.customFields,
|
customFields: pet.customFields,
|
||||||
image: pet.image,
|
image: pet.image,
|
||||||
|
temperamentScore: pet.temperamentScore,
|
||||||
|
temperamentFlags: pet.temperamentFlags,
|
||||||
|
medicalAlerts: pet.medicalAlerts,
|
||||||
|
preferredCuts: pet.preferredCuts,
|
||||||
|
coatType: pet.coatType,
|
||||||
|
petSizeCategory: pet.petSizeCategory,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -922,8 +990,52 @@ async function seed() {
|
|||||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
.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 } });
|
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||||
await db.insert(schema.pets)
|
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) })
|
.values({
|
||||||
.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) } });
|
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
|
// Create one completed appointment for this client
|
||||||
const apptId = uuid();
|
const apptId = uuid();
|
||||||
const svcIdx = 0;
|
const svcIdx = 0;
|
||||||
|
|||||||
+112
-1
@@ -1,7 +1,18 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
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 type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -97,6 +108,106 @@ petsRouter.get("/:id", async (c) => {
|
|||||||
return c.json(row);
|
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) => {
|
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||||
|
|||||||
Reference in New Issue
Block a user