Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56b20a3457 | |||
| 1aab3bf4e8 | |||
| 1891b9c523 | |||
| 0ab16b82e0 | |||
| 981a257d2d | |||
| a14bb5e17d | |||
| 280c699d0d | |||
| 5d6bc06295 | |||
| 53677b1420 | |||
| 0a3eb8a282 | |||
| b5f964c1ff | |||
| 86a6e3245c |
+9
-4
@@ -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,5 +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 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"]
|
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||||
|
|||||||
+57
-2
@@ -270,6 +270,10 @@ const medicalAlertPool: MedicalAlert[] = [
|
|||||||
{ id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" },
|
{ 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: "Luxating patella — short walks only", severity: "medium" },
|
||||||
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
|
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
|
||||||
|
{ id: "", type: "behavioral", description: "Anxiety — calm environment preferred", severity: "low" },
|
||||||
|
{ id: "", type: "behavioral", description: "Fear-based aggression — approach with caution", severity: "high" },
|
||||||
|
{ id: "", type: "skin", description: "Contact dermatitis — avoid harsh chemicals", severity: "medium" },
|
||||||
|
{ id: "", type: "skin", description: "Hot spots — monitor and report any worsening", severity: "high" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const preferredCutPool: string[] = [
|
const preferredCutPool: string[] = [
|
||||||
@@ -605,8 +609,45 @@ async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
|
|||||||
.from(schema.pets)
|
.from(schema.pets)
|
||||||
.where(eq(schema.pets.id, pet.id))
|
.where(eq(schema.pets.id, pet.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`✓ UAT Pet '${existing.name}' already exists — skipping`);
|
// Upsert so extended fields are always populated on re-runs
|
||||||
|
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,
|
||||||
|
temperamentScore: randInt(1, 5),
|
||||||
|
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||||
|
medicalAlerts: [],
|
||||||
|
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||||
|
coatType: pick(coatTypePool),
|
||||||
|
petSizeCategory: pick(petSizeCategoryPool),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.pets.id,
|
||||||
|
set: {
|
||||||
|
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,
|
||||||
|
temperamentScore: randInt(1, 5),
|
||||||
|
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||||
|
medicalAlerts: [],
|
||||||
|
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||||
|
coatType: pick(coatTypePool),
|
||||||
|
petSizeCategory: pick(petSizeCategoryPool),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✓ Upserted UAT pet '${pet.name}' with extended fields`);
|
||||||
} else {
|
} else {
|
||||||
await db.insert(schema.pets).values({
|
await db.insert(schema.pets).values({
|
||||||
id: pet.id,
|
id: pet.id,
|
||||||
@@ -617,8 +658,14 @@ async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
|
|||||||
weightKg: pet.weight,
|
weightKg: pet.weight,
|
||||||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||||||
image: pet.image,
|
image: pet.image,
|
||||||
|
temperamentScore: randInt(1, 5),
|
||||||
|
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||||
|
medicalAlerts: [],
|
||||||
|
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||||
|
coatType: pick(coatTypePool),
|
||||||
|
petSizeCategory: pick(petSizeCategoryPool),
|
||||||
});
|
});
|
||||||
console.log(`✓ Created UAT pet '${pet.name}'`);
|
console.log(`✓ Created UAT pet '${pet.name}' with extended fields`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1058,6 +1105,14 @@ async function seed() {
|
|||||||
temperamentScore: randInt(1, 5),
|
temperamentScore: randInt(1, 5),
|
||||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||||
medicalAlerts: (() => {
|
medicalAlerts: (() => {
|
||||||
|
// Deterministic alerts for UAT AC pets
|
||||||
|
if (uc.petName === "TestCooper") {
|
||||||
|
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||||
|
}
|
||||||
|
if (uc.petName === "TestRocky") {
|
||||||
|
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||||
|
}
|
||||||
|
// Other UAT pets: random
|
||||||
if (rand() < 0.3) {
|
if (rand() < 0.3) {
|
||||||
const count = rand() < 0.7 ? 1 : 2;
|
const count = rand() < 0.7 ? 1 : 2;
|
||||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||||
|
|||||||
+113
-1
@@ -1,7 +1,19 @@
|
|||||||
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,
|
||||||
|
sql,
|
||||||
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -97,6 +109,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 [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) => {
|
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