Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64891bd260 | |||
| f460729d8d | |||
| 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"]
|
||||||
|
|||||||
@@ -178,9 +178,6 @@ 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, {
|
||||||
@@ -191,67 +188,25 @@ 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: (cols?: Record<string, unknown>) => {
|
select: () => ({
|
||||||
selectedColumns = {};
|
from: (table: unknown) => {
|
||||||
if (cols) {
|
const name = (table as { _name?: string })._name;
|
||||||
// Inspect cols to find sql-aliased expressions and their aliases
|
if (name === "pets") return makeChainable(mock.pets);
|
||||||
for (const [alias, expr] of Object.entries(cols)) {
|
if (name === "appointments") return makeChainable(mock.appointments);
|
||||||
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
|
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
||||||
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
|
if (name === "staff") return makeChainable(mock.staffMembers);
|
||||||
// Detect count(*) queries
|
if (name === "services") return makeChainable(mock.services);
|
||||||
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")) {
|
return makeChainable([]);
|
||||||
// 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: () => [{}] }) }),
|
||||||
@@ -267,7 +222,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: sqlMock,
|
sql: vi.fn((str: string) => str),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -624,63 +624,6 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Client: UAT Customer ─────────────────────────────────────────────────────
|
|
||||||
// Only uat-customer is a real end-user who needs a clients row.
|
|
||||||
// uat-groomer and uat-super are staff — they have staff records, not client records.
|
|
||||||
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
|
|
||||||
const [uatCustomerRow] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.clients)
|
|
||||||
.where(eq(schema.clients.email, "uat-customer@groombook.dev"))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let uatCustomerClientId: string;
|
|
||||||
if (uatCustomerRow) {
|
|
||||||
uatCustomerClientId = uatCustomerRow.id;
|
|
||||||
console.log(`✓ UAT Customer client record already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
const [created] = await db
|
|
||||||
.insert(schema.clients)
|
|
||||||
.values({
|
|
||||||
id: UAT_CUSTOMER_ID,
|
|
||||||
email: "uat-customer@groombook.dev",
|
|
||||||
name: "UAT Customer",
|
|
||||||
phone: "555-0102",
|
|
||||||
address: "1 UAT Lane, Test City, CA 90210",
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
uatCustomerClientId = created!.id;
|
|
||||||
console.log(`✓ Created client 'UAT Customer' for SSO bridge`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pets: UAT Customer's dogs ────────────────────────────────────────────────
|
|
||||||
const uatCustomerPets = [
|
|
||||||
{ id: "c0000001-0000-0000-0000-000000000002", name: "UAT Pup Alpha", species: "Dog", breed: "Beagle", weight: "12.00", dob: "2022-03-10", image: "/demo-pets/dog-beagle.png" },
|
|
||||||
{ id: "c0000001-0000-0000-0000-000000000003", name: "UAT Pup Beta", species: "Dog", breed: "Labrador", weight: "28.00", dob: "2021-07-22", image: "/demo-pets/dog-labrador.png" },
|
|
||||||
];
|
|
||||||
for (const pet of uatCustomerPets) {
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.pets)
|
|
||||||
.where(eq(schema.pets.id, pet.id))
|
|
||||||
.limit(1);
|
|
||||||
if (existing) {
|
|
||||||
console.log(`✓ UAT Pet '${existing.name}' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created UAT pet '${pet.name}'`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
|||||||
const CLIENT_EMAIL = "alice@example.com";
|
const CLIENT_EMAIL = "alice@example.com";
|
||||||
const CLIENT_NAME = "Alice Smith";
|
const CLIENT_NAME = "Alice Smith";
|
||||||
|
|
||||||
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
|
|
||||||
const UAT_CUSTOMER_EMAIL = "uat-customer@groombook.dev";
|
|
||||||
const UAT_CUSTOMER_NAME = "UAT Customer";
|
|
||||||
|
|
||||||
const BETTER_AUTH_SESSION = {
|
const BETTER_AUTH_SESSION = {
|
||||||
user: {
|
user: {
|
||||||
id: "auth-user-001",
|
id: "auth-user-001",
|
||||||
@@ -167,33 +163,6 @@ describe("POST /portal/session-from-auth", () => {
|
|||||||
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
|
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 201 for uat-customer SSO bridge with correct clientId and clientName", async () => {
|
|
||||||
const uatAuthSession = {
|
|
||||||
user: {
|
|
||||||
id: "auth-user-uat-customer",
|
|
||||||
email: UAT_CUSTOMER_EMAIL,
|
|
||||||
name: UAT_CUSTOMER_NAME,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
id: "ba-session-uat-customer",
|
|
||||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
mockGetSession.mockResolvedValue(uatAuthSession);
|
|
||||||
mockClientRow = { id: UAT_CUSTOMER_ID, email: UAT_CUSTOMER_EMAIL, name: UAT_CUSTOMER_NAME };
|
|
||||||
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
|
|
||||||
const res = await app.request("/portal/session-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body).toHaveProperty("sessionId");
|
|
||||||
expect(body.clientId).toBe(UAT_CUSTOMER_ID);
|
|
||||||
expect(body.clientName).toBe(UAT_CUSTOMER_NAME);
|
|
||||||
expect(insertedSession).not.toBeNull();
|
|
||||||
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 503 when auth is not configured", async () => {
|
it("returns 503 when auth is not configured", async () => {
|
||||||
mockGetAuth.mockImplementation(() => {
|
mockGetAuth.mockImplementation(() => {
|
||||||
throw new Error("Auth not initialized");
|
throw new Error("Auth not initialized");
|
||||||
|
|||||||
+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