Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64891bd260 | |||
| f460729d8d | |||
| 86a6e3245c | |||
| 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
|
||||
|
||||
+9
-3
@@ -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,4 +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 install -g pnpm@9.15.4
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV COREPACK_ENABLE_STRICT=0
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||
|
||||
+114
-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",
|
||||
@@ -853,6 +903,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 +950,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 +990,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;
|
||||
|
||||
+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