Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1507f8b17 |
@@ -78,8 +78,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
|
||||||
driver-opts: network=host
|
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -95,7 +93,6 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: runner
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
provenance: false
|
|
||||||
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/groombook/api:latest' || '' }}
|
||||||
@@ -109,7 +106,6 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: migrate
|
target: migrate
|
||||||
push: true
|
push: true
|
||||||
provenance: false
|
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||||
@@ -123,7 +119,6 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: seed
|
target: seed
|
||||||
push: true
|
push: true
|
||||||
provenance: false
|
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||||
@@ -137,7 +132,6 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
target: reset
|
target: reset
|
||||||
push: true
|
push: true
|
||||||
provenance: false
|
|
||||||
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' || '' }}
|
||||||
|
|||||||
+10
-22
@@ -5,19 +5,14 @@ WORKDIR /app
|
|||||||
# Install deps
|
# Install deps
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
COPY packages/db/package.json packages/db/
|
COPY apps/api/package.json apps/api/
|
||||||
COPY packages/types/package.json packages/types/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
RUN mkdir -p /home/node/.cache/node/corepack
|
RUN mkdir -p /home/node/.cache/node/corepack
|
||||||
COPY packages/ packages/
|
COPY apps/api/ apps/api/
|
||||||
COPY src/ src/
|
RUN pnpm --filter @groombook/api build
|
||||||
COPY tsconfig.json ./
|
|
||||||
RUN pnpm --filter @groombook/types build && \
|
|
||||||
pnpm --filter @groombook/db build && \
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
@@ -25,29 +20,22 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/apps/api/package.json apps/api/
|
||||||
COPY --from=builder /app/dist dist/
|
COPY --from=builder /app/apps/api/dist apps/api/dist
|
||||||
COPY --from=builder /app/packages/db/package.json packages/db/
|
|
||||||
COPY --from=builder /app/packages/db/dist packages/db/dist
|
|
||||||
COPY --from=builder /app/packages/types/package.json packages/types/
|
|
||||||
COPY --from=builder /app/packages/types/dist packages/types/dist
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD curl -f http://localhost:3000/health || exit 1
|
CMD curl -f http://localhost:3000/health || exit 1
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "apps/api/dist/index.js"]
|
||||||
|
|
||||||
# Migrate stage — runs drizzle-kit migrate against the database
|
|
||||||
FROM builder AS migrate
|
FROM builder AS migrate
|
||||||
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
|
CMD ["pnpm", "--filter", "@groombook/api", "db:migrate"]
|
||||||
|
|
||||||
# Seed stage — populates the database with test data
|
|
||||||
FROM builder AS seed
|
FROM builder AS seed
|
||||||
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
|
CMD ["pnpm", "--filter", "@groombook/api", "db:seed"]
|
||||||
|
|
||||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
|
||||||
FROM builder AS reset
|
FROM builder AS reset
|
||||||
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
CMD ["pnpm", "--filter", "@groombook/api", "db:reset"]
|
||||||
@@ -21,14 +21,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
|
|
||||||
## Test Cases
|
## Test Cases
|
||||||
|
|
||||||
### 4.0 Health Check
|
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
|
||||||
|---|----------|-------|----------|
|
|
||||||
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
|
||||||
|
|
||||||
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
|
|
||||||
|
|
||||||
### 4.1 Authentication
|
### 4.1 Authentication
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
@@ -78,10 +70,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| TC-API-3.13 | Reject too many temperamentFlags | POST /api/pets with 21 temperamentFlags | 400 Bad Request, max 20 flags enforced |
|
| TC-API-3.13 | Reject too many temperamentFlags | POST /api/pets with 21 temperamentFlags | 400 Bad Request, max 20 flags enforced |
|
||||||
| TC-API-3.14 | Reject too many preferredCuts | POST /api/pets with 21 preferredCuts | 400 Bad Request, max 20 cuts enforced |
|
| TC-API-3.14 | Reject too many preferredCuts | POST /api/pets with 21 preferredCuts | 400 Bad Request, max 20 cuts enforced |
|
||||||
| TC-API-3.15 | Reject too many medicalAlerts | POST /api/pets with 51 medicalAlerts | 400 Bad Request, max 50 alerts enforced |
|
| TC-API-3.15 | Reject too many medicalAlerts | POST /api/pets with 51 medicalAlerts | 400 Bad Request, max 50 alerts enforced |
|
||||||
| TC-API-3.16 | Get pet profile summary | GET /api/pets/{id}/profile-summary | 200 OK, aggregated profile with grooming history, visit count, upcoming appointment |
|
|
||||||
| TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden |
|
|
||||||
| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) |
|
|
||||||
| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) |
|
|
||||||
|
|
||||||
### 4.4 Appointment Scheduling
|
### 4.4 Appointment Scheduling
|
||||||
|
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|
||||||
import { petsRouter } from "../routes/pets.js";
|
|
||||||
|
|
||||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MANAGER: StaffRow = {
|
|
||||||
id: "staff-manager-id",
|
|
||||||
oidcSub: "oidc-manager-sub",
|
|
||||||
userId: null,
|
|
||||||
role: "manager",
|
|
||||||
isSuperUser: true,
|
|
||||||
name: "Manager McManager",
|
|
||||||
email: "manager@example.com",
|
|
||||||
active: true,
|
|
||||||
icalToken: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const GROOMER: StaffRow = {
|
|
||||||
id: "staff-groomer-id",
|
|
||||||
oidcSub: "oidc-groomer-sub",
|
|
||||||
userId: null,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
name: "Groomer McGroome",
|
|
||||||
email: "groomer@example.com",
|
|
||||||
active: true,
|
|
||||||
icalToken: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CLIENT_ID = "client-uuid-summary";
|
|
||||||
const PET_ID = "pet-uuid-summary";
|
|
||||||
|
|
||||||
interface MockState {
|
|
||||||
pets: Record<string, unknown>[];
|
|
||||||
appointments: Record<string, unknown>[];
|
|
||||||
groomingLogs: Record<string, unknown>[];
|
|
||||||
staffMembers: Record<string, unknown>[];
|
|
||||||
services: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mock: MockState;
|
|
||||||
|
|
||||||
function resetMock() {
|
|
||||||
mock = {
|
|
||||||
pets: [{
|
|
||||||
id: PET_ID,
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
name: "Biscuit",
|
|
||||||
species: "dog",
|
|
||||||
breed: "Golden Retriever",
|
|
||||||
weightKg: "30.00",
|
|
||||||
dateOfBirth: null,
|
|
||||||
healthAlerts: null,
|
|
||||||
groomingNotes: null,
|
|
||||||
cutStyle: null,
|
|
||||||
shampooPreference: null,
|
|
||||||
specialCareNotes: null,
|
|
||||||
customFields: {},
|
|
||||||
photoKey: null,
|
|
||||||
photoUploadedAt: null,
|
|
||||||
image: null,
|
|
||||||
coatType: "double",
|
|
||||||
temperamentScore: 3,
|
|
||||||
temperamentFlags: ["gentle"],
|
|
||||||
medicalAlerts: [],
|
|
||||||
preferredCuts: ["puppy cut"],
|
|
||||||
createdAt: new Date("2024-01-01"),
|
|
||||||
updatedAt: new Date("2024-01-01"),
|
|
||||||
}],
|
|
||||||
appointments: [
|
|
||||||
{
|
|
||||||
id: "appt-completed-1",
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
petId: PET_ID,
|
|
||||||
serviceId: "service-1",
|
|
||||||
staffId: "staff-groomer-id",
|
|
||||||
batherStaffId: null,
|
|
||||||
status: "completed",
|
|
||||||
startTime: new Date("2024-06-01T09:00:00Z"),
|
|
||||||
endTime: new Date("2024-06-01T11:00:00Z"),
|
|
||||||
notes: null,
|
|
||||||
priceCents: 6000,
|
|
||||||
seriesId: null,
|
|
||||||
seriesIndex: null,
|
|
||||||
groupId: null,
|
|
||||||
confirmationStatus: "confirmed",
|
|
||||||
confirmedAt: null,
|
|
||||||
cancelledAt: null,
|
|
||||||
confirmationToken: null,
|
|
||||||
customerNotes: null,
|
|
||||||
createdAt: new Date("2024-05-15"),
|
|
||||||
updatedAt: new Date("2024-05-15"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "appt-upcoming-1",
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
petId: PET_ID,
|
|
||||||
serviceId: "service-2",
|
|
||||||
staffId: "staff-groomer-id",
|
|
||||||
batherStaffId: null,
|
|
||||||
status: "confirmed",
|
|
||||||
startTime: new Date("2024-12-01T09:00:00Z"),
|
|
||||||
endTime: new Date("2024-12-01T11:00:00Z"),
|
|
||||||
notes: null,
|
|
||||||
priceCents: 6500,
|
|
||||||
seriesId: null,
|
|
||||||
seriesIndex: null,
|
|
||||||
groupId: null,
|
|
||||||
confirmationStatus: "confirmed",
|
|
||||||
confirmedAt: null,
|
|
||||||
cancelledAt: null,
|
|
||||||
confirmationToken: null,
|
|
||||||
customerNotes: null,
|
|
||||||
createdAt: new Date("2024-11-01"),
|
|
||||||
updatedAt: new Date("2024-11-01"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
groomingLogs: [
|
|
||||||
{
|
|
||||||
id: "log-1",
|
|
||||||
petId: PET_ID,
|
|
||||||
appointmentId: "appt-completed-1",
|
|
||||||
staffId: "staff-groomer-id",
|
|
||||||
cutStyle: "puppy cut",
|
|
||||||
productsUsed: "oatmeal shampoo",
|
|
||||||
notes: "Trimmed nails",
|
|
||||||
groomedAt: new Date("2024-06-01T10:00:00Z"),
|
|
||||||
createdAt: new Date("2024-06-01T10:00:00Z"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
staffMembers: [
|
|
||||||
{
|
|
||||||
id: "staff-groomer-id",
|
|
||||||
name: "Groomer McGroome",
|
|
||||||
email: "groomer@example.com",
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
oidcSub: "oidc-groomer-sub",
|
|
||||||
userId: null,
|
|
||||||
icalToken: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "staff-manager-id",
|
|
||||||
name: "Manager McManager",
|
|
||||||
email: "manager@example.com",
|
|
||||||
role: "manager",
|
|
||||||
isSuperUser: true,
|
|
||||||
active: true,
|
|
||||||
oidcSub: "oidc-manager-sub",
|
|
||||||
userId: null,
|
|
||||||
icalToken: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
services: [
|
|
||||||
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
|
|
||||||
{ id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("../db/index.js", () => {
|
|
||||||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
|
||||||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
|
||||||
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
|
|
||||||
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
|
|
||||||
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
|
|
||||||
|
|
||||||
function makeChainable(rows: unknown[]) {
|
|
||||||
const arr = rows as unknown[];
|
|
||||||
return new Proxy(arr, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") {
|
|
||||||
return () => makeChainable(target);
|
|
||||||
}
|
|
||||||
if (prop === Symbol.iterator) {
|
|
||||||
return function* () { for (const v of target) yield v; };
|
|
||||||
}
|
|
||||||
// @ts-expect-error proxy
|
|
||||||
return target[prop];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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([]);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
|
|
||||||
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
|
||||||
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
|
||||||
}),
|
|
||||||
pets,
|
|
||||||
appointments,
|
|
||||||
groomingVisitLogs,
|
|
||||||
staff,
|
|
||||||
services,
|
|
||||||
and: vi.fn((a: unknown, b: unknown) => [a, b]),
|
|
||||||
desc: vi.fn((c: unknown) => c),
|
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeApp(staff: StaffRow = MANAGER) {
|
|
||||||
const app = new Hono<AppEnv>();
|
|
||||||
app.use("*", async (c, next) => {
|
|
||||||
c.set("staff", staff);
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
return app.route("/pets", petsRouter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("GET /:id/profile-summary", () => {
|
|
||||||
beforeEach(resetMock);
|
|
||||||
|
|
||||||
it("returns 404 for non-existent pet", async () => {
|
|
||||||
const app = makeApp();
|
|
||||||
mock.pets = [];
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 403 for groomer with no pet linkage", async () => {
|
|
||||||
const app = makeApp(GROOMER);
|
|
||||||
// Groomer has no linkage to this pet's client — clear appointments
|
|
||||||
mock.appointments = [];
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns complete aggregated profile for manager", async () => {
|
|
||||||
const app = makeApp(MANAGER);
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.id).toBe(PET_ID);
|
|
||||||
expect(body.name).toBe("Biscuit");
|
|
||||||
expect(body.species).toBe("dog");
|
|
||||||
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
|
|
||||||
expect(body.lastVisitDate).toBeTruthy();
|
|
||||||
expect(body.visitCount).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groomer with pet linkage returns 200", async () => {
|
|
||||||
const app = makeApp(GROOMER);
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recentGroomingHistory is limited to 10 entries", async () => {
|
|
||||||
const app = makeApp(MANAGER);
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null upcomingAppointment when none scheduled", async () => {
|
|
||||||
const app = makeApp(MANAGER);
|
|
||||||
mock.appointments = [];
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.upcomingAppointment).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /:id/profile-summary — visitCount", () => {
|
|
||||||
beforeEach(resetMock);
|
|
||||||
|
|
||||||
it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => {
|
|
||||||
const app = makeApp(MANAGER);
|
|
||||||
// Add a second completed appointment
|
|
||||||
mock.appointments = [
|
|
||||||
...mock.appointments,
|
|
||||||
{
|
|
||||||
id: "appt-completed-2",
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
petId: PET_ID,
|
|
||||||
serviceId: "service-1",
|
|
||||||
staffId: "staff-groomer-id",
|
|
||||||
batherStaffId: null,
|
|
||||||
status: "completed",
|
|
||||||
startTime: new Date("2024-07-01T09:00:00Z"),
|
|
||||||
endTime: new Date("2024-07-01T11:00:00Z"),
|
|
||||||
notes: null,
|
|
||||||
priceCents: 6000,
|
|
||||||
seriesId: null,
|
|
||||||
seriesIndex: null,
|
|
||||||
groupId: null,
|
|
||||||
confirmationStatus: "confirmed",
|
|
||||||
confirmedAt: null,
|
|
||||||
cancelledAt: null,
|
|
||||||
confirmationToken: null,
|
|
||||||
customerNotes: null,
|
|
||||||
createdAt: new Date("2024-06-15"),
|
|
||||||
updatedAt: new Date("2024-06-15"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.visitCount).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns visitCount = 0 when no completed appointments", async () => {
|
|
||||||
const app = makeApp(MANAGER);
|
|
||||||
mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" }));
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.visitCount).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /:id/profile-summary — empty history", () => {
|
|
||||||
beforeEach(resetMock);
|
|
||||||
|
|
||||||
it("returns empty history array when no grooming logs", async () => {
|
|
||||||
const app = makeApp(MANAGER);
|
|
||||||
mock.groomingLogs = [];
|
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.recentGroomingHistory).toEqual([]);
|
|
||||||
expect(body.lastVisitDate).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-131
@@ -1,7 +1,7 @@
|
|||||||
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, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -283,133 +283,3 @@ petsRouter.get("/:petId/photo", async (c) => {
|
|||||||
const url = await getPresignedGetUrl(pet.photoKey);
|
const url = await getPresignedGetUrl(pet.photoKey);
|
||||||
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Profile Summary ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function groomerLinkageCheck(
|
|
||||||
db: ReturnType<typeof getDb>,
|
|
||||||
clientId: string,
|
|
||||||
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
|
|
||||||
): Promise<boolean> {
|
|
||||||
const [linkage] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.clientId, clientId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
return !!linkage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /:id/profile-summary
|
|
||||||
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
|
|
||||||
* Groomer RBAC: same visibility rules as GET /:id.
|
|
||||||
*/
|
|
||||||
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";
|
|
||||||
|
|
||||||
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
|
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
|
|
||||||
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent grooming history: last 10, with staff name join
|
|
||||||
const historyRows = await db
|
|
||||||
.select({
|
|
||||||
id: groomingVisitLogs.id,
|
|
||||||
petId: groomingVisitLogs.petId,
|
|
||||||
appointmentId: groomingVisitLogs.appointmentId,
|
|
||||||
staffId: groomingVisitLogs.staffId,
|
|
||||||
staffName: staff.name,
|
|
||||||
cutStyle: groomingVisitLogs.cutStyle,
|
|
||||||
productsUsed: groomingVisitLogs.productsUsed,
|
|
||||||
notes: groomingVisitLogs.notes,
|
|
||||||
groomedAt: groomingVisitLogs.groomedAt,
|
|
||||||
createdAt: groomingVisitLogs.createdAt,
|
|
||||||
})
|
|
||||||
.from(groomingVisitLogs)
|
|
||||||
.leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId))
|
|
||||||
.where(eq(groomingVisitLogs.petId, petId))
|
|
||||||
.orderBy(desc(groomingVisitLogs.groomedAt))
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
const recentGroomingHistory = historyRows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
petId: r.petId,
|
|
||||||
appointmentId: r.appointmentId,
|
|
||||||
staffId: r.staffId,
|
|
||||||
staffName: r.staffName,
|
|
||||||
cutStyle: r.cutStyle,
|
|
||||||
productsUsed: r.productsUsed,
|
|
||||||
notes: r.notes,
|
|
||||||
groomedAt: r.groomedAt?.toISOString() ?? null,
|
|
||||||
createdAt: r.createdAt?.toISOString() ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
|
|
||||||
|
|
||||||
// Completed appointment count for this pet
|
|
||||||
const [{ count: visitCount }] = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(appointments)
|
|
||||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
|
||||||
|
|
||||||
// Upcoming appointment: next scheduled or confirmed
|
|
||||||
const [nextAppt] = await db
|
|
||||||
.select({
|
|
||||||
id: appointments.id,
|
|
||||||
serviceId: appointments.serviceId,
|
|
||||||
staffId: appointments.staffId,
|
|
||||||
startTime: appointments.startTime,
|
|
||||||
endTime: appointments.endTime,
|
|
||||||
status: appointments.status,
|
|
||||||
serviceName: services.name,
|
|
||||||
staffName: staff.name,
|
|
||||||
})
|
|
||||||
.from(appointments)
|
|
||||||
.leftJoin(services, eq(services.id, appointments.serviceId))
|
|
||||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, petId),
|
|
||||||
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")),
|
|
||||||
gte(appointments.startTime, new Date())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(appointments.startTime)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const upcomingAppointment = nextAppt
|
|
||||||
? {
|
|
||||||
id: nextAppt.id,
|
|
||||||
serviceId: nextAppt.serviceId,
|
|
||||||
serviceName: nextAppt.serviceName,
|
|
||||||
staffId: nextAppt.staffId,
|
|
||||||
staffName: nextAppt.staffName,
|
|
||||||
startTime: nextAppt.startTime?.toISOString() ?? null,
|
|
||||||
endTime: nextAppt.endTime?.toISOString() ?? null,
|
|
||||||
status: nextAppt.status,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
...row,
|
|
||||||
recentGroomingHistory,
|
|
||||||
lastVisitDate,
|
|
||||||
visitCount,
|
|
||||||
upcomingAppointment,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
|
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
|
||||||
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
|
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
|
||||||
|
|
||||||
-- ─── Add columns to pets if missing, then cast to enums ──────────────────────
|
-- ─── Alter pets columns to use new enums ─────────────────────────────────────
|
||||||
|
|
||||||
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "coat_type" text;
|
|
||||||
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "pet_size_category" text;
|
|
||||||
ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type";
|
ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type";
|
||||||
ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category";
|
ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category";
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
-- no-op: journal entry exists but no schema change was needed
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- Migration: 0033_add_services_default_buffer_minutes.sql
|
|
||||||
-- Adds missing default_buffer_minutes column to services table.
|
|
||||||
-- 0031_buffer_rules was applied to the DB but its journal entry was missing,
|
|
||||||
-- so this ensures idempotent column addition for fresh DB restores.
|
|
||||||
|
|
||||||
ALTER TABLE "services" ADD COLUMN IF NOT EXISTS "default_buffer_minutes" integer DEFAULT 0 NOT NULL;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "0033_add_services_default_buffer_minutes",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"authProviderConfig": {
|
|
||||||
"name": "auth_provider_config",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
|
||||||
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
|
||||||
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
|
||||||
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
|
||||||
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
|
||||||
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
|
||||||
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
|
||||||
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {}
|
|
||||||
},
|
|
||||||
"businessSettings": {
|
|
||||||
"name": "business_settings",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
|
||||||
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
|
||||||
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
|
||||||
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
|
||||||
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
|
||||||
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {}
|
|
||||||
},
|
|
||||||
"clients": {
|
|
||||||
"name": "clients",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
|
||||||
"email": { "name": "email", "type": "text", "isNullable": true },
|
|
||||||
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
|
||||||
"address": { "name": "address", "type": "text", "isNullable": true },
|
|
||||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
|
||||||
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
|
||||||
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
|
||||||
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
|
||||||
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
|
||||||
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
|
||||||
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
|
||||||
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
|
||||||
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
|
||||||
},
|
|
||||||
"invoices": {
|
|
||||||
"name": "invoices",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
|
||||||
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
|
||||||
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
|
||||||
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
|
||||||
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
|
||||||
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
|
||||||
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
|
||||||
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
|
||||||
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
|
||||||
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
|
||||||
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
|
||||||
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
|
||||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
|
||||||
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {
|
|
||||||
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
|
||||||
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
|
||||||
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
|
||||||
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
|
||||||
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
|
||||||
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
|
||||||
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
|
||||||
},
|
|
||||||
"nativeEnums": {}
|
|
||||||
}
|
|
||||||
@@ -218,27 +218,6 @@
|
|||||||
"when": 1775828067192,
|
"when": 1775828067192,
|
||||||
"tag": "0030_messaging",
|
"tag": "0030_messaging",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 31,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775860800000,
|
|
||||||
"tag": "0031_buffer_rules",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 32,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775894400000,
|
|
||||||
"tag": "0032_staff_read_at",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 33,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1779500000000,
|
|
||||||
"tag": "0033_add_services_default_buffer_minutes",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -225,34 +225,3 @@ export interface MedicalAlert {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
|
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
|
||||||
|
|
||||||
export interface GroomingHistoryEntry {
|
|
||||||
id: string;
|
|
||||||
petId: string;
|
|
||||||
appointmentId: string | null;
|
|
||||||
staffId: string | null;
|
|
||||||
staffName: string | null;
|
|
||||||
cutStyle: string | null;
|
|
||||||
productsUsed: string | null;
|
|
||||||
notes: string | null;
|
|
||||||
groomedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpcomingAppointment {
|
|
||||||
id: string;
|
|
||||||
serviceId: string;
|
|
||||||
serviceName: string;
|
|
||||||
staffId: string | null;
|
|
||||||
staffName: string | null;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
status: AppointmentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PetProfileSummary extends Pet {
|
|
||||||
recentGroomingHistory: GroomingHistoryEntry[];
|
|
||||||
lastVisitDate: string | null;
|
|
||||||
visitCount: number;
|
|
||||||
upcomingAppointment: UpcomingAppointment | null;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-4
@@ -58,11 +58,8 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Health check — no auth required, registered on app at full path before auth middleware
|
// Health check (no auth required)
|
||||||
// /health: used by Dockerfile HEALTHCHECK and K8s readinessProbe/livenessProbe (port 3000 direct)
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
|
||||||
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
|
||||||
|
|
||||||
// Public booking routes — no auth required, must be registered before auth middleware
|
// Public booking routes — no auth required, must be registered before auth middleware
|
||||||
app.route("/api/book", bookRouter);
|
app.route("/api/book", bookRouter);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") {
|
if (c.req.path.startsWith("/api/auth/")) {
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user