Compare commits

..

2 Commits

Author SHA1 Message Date
groombook-cto[bot] 1f3896622d Merge branch 'main' into fix/gro-640-n-plus-one-email 2026-04-16 10:09:10 +00:00
Flea Flicker aea3b1f410 fix(GRO-640): replace N+1 queries in sendConfirmationEmail with single JOIN query 2026-04-15 10:49:43 +00:00
26 changed files with 311 additions and 643 deletions
+2 -2
View File
@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [main, dev] branches: [main]
pull_request: pull_request:
branches: [main, dev] branches: [main]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
ref: ref:
-90
View File
@@ -1,90 +0,0 @@
# Contributing to GroomBook
## Branch Strategy
GroomBook uses a three-branch GitOps model:
| Branch | Environment | Purpose |
|--------|-------------|---------|
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
## Developer Workflow
1. **Branch from `dev`** — create a feature or fix branch:
```bash
git checkout dev
git pull origin dev
git checkout -b feat/my-feature
```
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
```bash
gh pr create --base dev --title "feat: description (GRO-NNN)" \
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
```
3. **Pipeline gates before merge to `dev`:**
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
- CTO (The Dogfather) reviews second — architecture and final approval
- Both must approve; 2 approving reviews required by branch protection
## Promotion Flow
### Dev → UAT
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
```bash
gh pr create --base uat --head dev \
--title "chore: promote dev to uat (YYYY.MM.DD)" \
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
```
Gates:
- Shedward Scissorhands runs regression/acceptance tests
- Barkley Trimsworth performs security review
- CTO approves and merges (1 approving review required)
### UAT → Main (Production)
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
```bash
gh pr create --base main --head uat \
--title "chore: promote uat to main (YYYY.MM.DD)" \
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
```
Gates:
- CEO (Scrubs McBarkley) reviews for business alignment and merges
- 1 approving review required; triggers auto-deploy to Production
## Branch Protection Summary
| Branch | Required Approvals | Who approves |
|--------|--------------------|-------------|
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
| `uat` | 1 | CTO (The Dogfather) |
| `main` | 1 | CEO (Scrubs McBarkley) |
Force-pushes and branch deletions are disabled on all three branches.
## Commit Style
Use [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — new feature
- `fix:` — bug fix
- `chore:` — maintenance (dependency updates, build config, promotions)
- `docs:` — documentation only
- `ci:` — CI/CD changes
- `refactor:` — code restructure without behaviour change
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
## Questions?
Open a Paperclip issue in the GRO project or ask in the team channel.
+2 -5
View File
@@ -12,8 +12,7 @@ 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
corepack prepare pnpm@9.15.4 --activate
COPY packages/ packages/ COPY packages/ packages/
COPY apps/api/ apps/api/ COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \ RUN pnpm --filter @groombook/types build && \
@@ -22,9 +21,7 @@ RUN pnpm --filter @groombook/types build && \
# Runtime # Runtime
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN corepack enable && \ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
mkdir -p /home/node/.cache/node/corepack && \
corepack prepare pnpm@9.15.4 --activate
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
+2 -62
View File
@@ -72,60 +72,6 @@ app.route("/api/webhooks/stripe", webhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally // Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter); app.route("/api/dev", devRouter);
// Magic bytes for allowed image types
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
};
/**
* Validates that the given base64 content matches the declared MIME type
* by checking magic bytes. Returns null if valid, or the field to clear if not.
*/
function validateLogoMagicBytes(
logoBase64: string | null,
logoMimeType: string | null
): "logoBase64" | "logoMimeType" | null {
if (!logoBase64 || !logoMimeType) return null;
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
try {
const binary = Buffer.from(logoBase64, "base64");
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
if (logoMimeType === "image/webp") {
if (binary.length < 12) return "logoBase64";
const webpMagic = binary.slice(0, 4);
const webpSig = binary.slice(8, 12);
if (
webpMagic[0] !== 0x52 ||
webpMagic[1] !== 0x49 ||
webpMagic[2] !== 0x46 ||
webpMagic[3] !== 0x46 ||
webpSig[0] !== 0x57 ||
webpSig[1] !== 0x45 ||
webpSig[2] !== 0x42 ||
webpSig[3] !== 0x50
) {
return "logoBase64";
}
return null;
}
// All other types: check prefix
if (binary.length < expectedMagic.length) return "logoBase64";
for (let i = 0; i < expectedMagic.length; i++) {
if (binary[i] !== expectedMagic[i]) return "logoBase64";
}
return null;
} catch {
return "logoBase64";
}
}
// Public branding endpoint — no auth required, returns business name/colors/logo // Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => { app.get("/api/branding", async (c) => {
const db = getDb(); const db = getDb();
@@ -141,19 +87,13 @@ app.get("/api/branding", async (c) => {
} }
} }
// Defensive: validate magic bytes to prevent MIME type confusion attacks
// via the legacy base64 logo fields
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
return c.json({ return c.json({
businessName: settings.businessName, businessName: settings.businessName,
primaryColor: settings.primaryColor, primaryColor: settings.primaryColor,
accentColor: settings.accentColor, accentColor: settings.accentColor,
logoUrl, logoUrl,
logoBase64: safeLogoBase64, logoBase64: settings.logoBase64,
logoMimeType: safeLogoMimeType, logoMimeType: settings.logoMimeType,
}); });
}); });
-45
View File
@@ -1,45 +0,0 @@
import type { MiddlewareHandler } from "hono";
import { getDb, impersonationAuditLogs } from "@groombook/db";
import type { PortalEnv } from "./portalSession.js";
/**
* Server-side audit logging middleware for portal routes.
* Applied after validatePortalSession in the middleware chain.
*
* After the route handler completes (await next()), inserts an audit log entry
* into impersonationAuditLogs:
* - sessionId: from c.get("portalSessionId")
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
* - pageVisited: c.req.path
* - metadata: { method, statusCode: c.res.status }
*
* Log entries are written for both success and error responses.
* Does NOT throw if audit logging fails — errors are logged but the user's
* request is not affected.
*/
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
await next();
const sessionId = c.get("portalSessionId");
if (!sessionId) return;
const method = c.req.method;
const routePath = c.req.path;
const pageVisited = c.req.path;
const statusCode = c.res.status;
try {
const db = getDb();
await db
.insert(impersonationAuditLogs)
.values({
sessionId,
action: `${method} ${routePath}`,
pageVisited,
metadata: { method, statusCode },
})
.returning();
} catch (err) {
console.error("[portalAudit] Failed to write audit log:", err);
}
};
-40
View File
@@ -1,40 +0,0 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
export interface PortalEnv {
Variables: {
portalClientId: string;
portalSessionId: string;
};
}
/**
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
* Must be applied to all portal routes.
*
* Reads x-session-id from request headers, queries impersonationSessions for a row where
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
* Returns 401 if session is invalid/missing/expired.
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
*/
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const db = getDb();
const [session] = await db
.select()
.from(impersonationSessions)
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
c.set("portalClientId", session.clientId);
c.set("portalSessionId", session.id);
await next();
};
+6 -66
View File
@@ -42,13 +42,6 @@ const updateInvoiceSchema = z.object({
taxCents: z.number().int().nonnegative().optional(), taxCents: z.number().int().nonnegative().optional(),
tipCents: z.number().int().nonnegative().optional(), tipCents: z.number().int().nonnegative().optional(),
notes: z.string().max(2000).nullable().optional(), notes: z.string().max(2000).nullable().optional(),
tipSplits: z.array(
z.object({
staffId: z.string().uuid().nullable(),
staffName: z.string().min(1).max(200),
sharePct: z.number().min(0).max(100),
})
).optional(),
}); });
// List invoices // List invoices
@@ -341,30 +334,7 @@ invoicesRouter.patch(
} }
} }
// Tip split validation when marking as paid with a tip const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
const effectiveTipCents = body.tipCents ?? current.tipCents;
if (body.status === "paid" && effectiveTipCents > 0) {
if (body.tipSplits !== undefined) {
if (body.tipSplits.length === 0) {
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
}
const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
if (totalBps !== 10000) {
return c.json({ error: "Split percentages must sum to 100" }, 422);
}
} else {
const existingSplits = await db
.select({ id: invoiceTipSplits.id })
.from(invoiceTipSplits)
.where(eq(invoiceTipSplits.invoiceId, id));
if (existingSplits.length === 0) {
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
}
}
}
const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body;
const update: Record<string, unknown> = { ...bodyWithoutSplits, updatedAt: new Date() };
// Auto-set paidAt when marking as paid // Auto-set paidAt when marking as paid
if (body.status === "paid" && !body.paidAt && !current.paidAt) { if (body.status === "paid" && !body.paidAt && !current.paidAt) {
@@ -378,41 +348,11 @@ invoicesRouter.patch(
update.totalCents = current.subtotalCents + newTaxCents + newTipCents; update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
} }
const [updated] = await db.transaction(async (tx) => { const [updated] = await db
const [upd] = await tx .update(invoices)
.update(invoices) .set(update)
.set(update) .where(eq(invoices.id, id))
.where(eq(invoices.id, id)) .returning();
.returning();
// Atomically save tip splits when marking paid with provided splits
if (
body.status === "paid" &&
effectiveTipCents > 0 &&
incomingTipSplits !== undefined &&
incomingTipSplits.length > 0
) {
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
let remaining = effectiveTipCents;
const rows = incomingTipSplits.map((s, i) => {
const isLast = i === incomingTipSplits.length - 1;
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * effectiveTipCents);
if (!isLast) remaining -= shareCents;
return {
invoiceId: id,
staffId: s.staffId,
staffName: s.staffName,
sharePct: s.sharePct.toFixed(2),
shareCents,
};
});
await tx.insert(invoiceTipSplits).values(rows);
}
return [upd];
});
const lineItems = await db const lineItems = await db
.select() .select()
+128 -28
View File
@@ -1,22 +1,33 @@
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 { eq, inArray } from "@groombook/db"; import { and, eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import { validatePortalSession } from "../middleware/portalSession.js"; import type { AppEnv } from "../middleware/rbac.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
export const portalRouter = new Hono<PortalEnv>(); export const portalRouter = new Hono<AppEnv>();
// Apply middleware to all portal routes // ─── Session helper ───────────────────────────────────────────────────────────
portalRouter.use("/*", validatePortalSession, portalAudit);
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
if (!sessionId) return null;
const db = getDb();
const [session] = await db
.select()
.from(impersonationSessions)
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
.limit(1);
if (!session || session.expiresAt <= new Date()) return null;
return session.clientId;
}
// ─── GET routes ────────────────────────────────────────────────────────────── // ─── GET routes ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => { portalRouter.get("/me", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return c.json({ error: "Not found" }, 404); if (!client) return c.json({ error: "Not found" }, 404);
@@ -38,8 +49,11 @@ portalRouter.get("/services", async (c) => {
portalRouter.get("/appointments", async (c) => { portalRouter.get("/appointments", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const now = new Date();
const allAppts = await db const allAppts = await db
.select({ .select({
id: appointments.id, id: appointments.id,
@@ -59,15 +73,12 @@ portalRouter.get("/appointments", async (c) => {
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
const serviceIds = allAppts.map(a => a.serviceId).filter((id): id is string => id !== null);
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({ const appts = allAppts.map(a => ({
id: a.id, id: a.id,
@@ -78,16 +89,21 @@ portalRouter.get("/appointments", async (c) => {
customerNotes: a.customerNotes, customerNotes: a.customerNotes,
notes: a.notes, notes: a.notes,
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name, duration: serviceMap[a.serviceId]?.durationMinutes, price: serviceMap[a.serviceId]?.basePriceCents } : null, service: a.serviceId ? { id: a.serviceId } : null,
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
})); }));
return c.json({ appointments: appts }); const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
return c.json({ upcoming, past });
}); });
portalRouter.get("/pets", async (c) => { portalRouter.get("/pets", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
@@ -95,7 +111,9 @@ portalRouter.get("/pets", async (c) => {
portalRouter.get("/invoices", async (c) => { portalRouter.get("/invoices", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
const invoiceIds = clientInvoices.map(i => i.id); const invoiceIds = clientInvoices.map(i => i.id);
@@ -130,7 +148,12 @@ portalRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -173,7 +196,12 @@ portalRouter.patch(
portalRouter.post("/appointments/:id/confirm", async (c) => { portalRouter.post("/appointments/:id/confirm", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -222,7 +250,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
portalRouter.post("/appointments/:id/cancel", async (c) => { portalRouter.post("/appointments/:id/cancel", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -286,7 +319,28 @@ portalRouter.post(
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
let clientId: string | null = null;
if (sessionId) {
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (session && session.expiresAt > new Date()) {
clientId = session.clientId;
}
}
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [entry] = await db const [entry] = await db
.insert(waitlistEntries) .insert(waitlistEntries)
@@ -310,7 +364,26 @@ portalRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const [existing] = await db const [existing] = await db
.select() .select()
@@ -319,7 +392,7 @@ portalRouter.patch(
.limit(1); .limit(1);
if (!existing) return c.json({ error: "Not found" }, 404); if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) { if (existing.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403); return c.json({ error: "Forbidden" }, 403);
} }
@@ -341,7 +414,26 @@ portalRouter.patch(
portalRouter.delete("/waitlist/:id", async (c) => { portalRouter.delete("/waitlist/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const [entry] = await db const [entry] = await db
.select() .select()
@@ -350,7 +442,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.limit(1); .limit(1);
if (!entry) return c.json({ error: "Not found" }, 404); if (!entry) return c.json({ error: "Not found" }, 404);
if (entry.clientId !== clientId) { if (entry.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403); return c.json({ error: "Forbidden" }, 403);
} }
@@ -383,7 +475,9 @@ portalRouter.post(
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db const invoiceRows = await db
.select() .select()
@@ -420,7 +514,9 @@ portalRouter.post(
); );
portalRouter.get("/payment-methods", async (c) => { portalRouter.get("/payment-methods", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId); const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
@@ -428,7 +524,9 @@ portalRouter.get("/payment-methods", async (c) => {
}); });
portalRouter.post("/payment-methods", async (c) => { portalRouter.post("/payment-methods", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId); const customerId = await getOrCreateStripeCustomer(clientId);
@@ -441,7 +539,9 @@ portalRouter.post("/payment-methods", async (c) => {
}); });
portalRouter.delete("/payment-methods/:id", async (c) => { portalRouter.delete("/payment-methods/:id", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id"); const paymentMethodId = c.req.param("id");
+68 -78
View File
@@ -5,7 +5,6 @@ import {
eq, eq,
getDb, getDb,
gte, gte,
inArray,
lt, lt,
appointments, appointments,
clients, clients,
@@ -60,77 +59,68 @@ export async function runReminderCheck(): Promise<void> {
) )
); );
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
if (appointmentIds.length === 0) continue;
// Bulk check: which appointments already have email and SMS reminders sent?
const sentRows = await db
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.reminderType, window.label),
appointmentIds.length === 1
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
: inArray(reminderLogs.appointmentId, appointmentIds)
)
);
const sentEmail = new Set(
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
);
const sentSms = new Set(
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
);
// Bulk JOIN: fetch all client/pet/service/staff data in one query
const joinedRows = await db
.select({
appointmentId: appointments.id,
startTime: appointments.startTime,
clientId: appointments.clientId,
petId: appointments.petId,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
confirmationToken: appointments.confirmationToken,
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
clientSmsOptIn: clients.smsOptIn,
clientPhone: clients.phone,
petName: pets.name,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(appointments.clientId, clients.id))
.innerJoin(pets, eq(appointments.petId, pets.id))
.innerJoin(services, eq(appointments.serviceId, services.id))
.leftJoin(staff, eq(appointments.staffId, staff.id))
.where(
and(
gte(appointments.startTime, windowStart),
lt(appointments.startTime, windowEnd),
eq(appointments.status, "scheduled")
)
);
const appointmentMap = new Map<string, typeof joinedRows[number]>();
for (const row of joinedRows) {
appointmentMap.set(row.appointmentId, row);
}
for (const appt of upcoming) { for (const appt of upcoming) {
const joined = appointmentMap.get(appt.id as string); const [emailLog] = await db
if (!joined) continue; .select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
)
)
.limit(1);
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined; const [smsLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
if (!clientEmail || clientEmailOptOut) continue; const [client] = await db
if (!petName || !serviceName) continue; .select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
const emailSent = sentEmail.has(appt.id as string); if (!client || !client.email || client.emailOptOut) continue;
const smsSent = sentSms.has(appt.id as string);
const [pet] = await db
.select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
let groomerName: string | null = null;
if (appt.staffId) {
const [groomer] = await db
.select({ name: staff.name })
.from(staff)
.where(eq(staff.id, appt.staffId))
.limit(1);
groomerName = groomer?.name ?? null;
}
if (!pet || !service) continue;
let confirmationToken = appt.confirmationToken; let confirmationToken = appt.confirmationToken;
if (!confirmationToken) { if (!confirmationToken) {
@@ -141,15 +131,15 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
if (!emailSent) { if (!emailLog) {
const sent = await sendEmail( const sent = await sendEmail(
buildReminderEmail( buildReminderEmail(
clientEmail, client.email,
{ {
clientName, clientName: client.name,
petName, petName: pet.name,
serviceName, serviceName: service.name,
groomerName: staffName, groomerName,
startTime: appt.startTime, startTime: appt.startTime,
}, },
window.hours, window.hours,
@@ -165,20 +155,20 @@ export async function runReminderCheck(): Promise<void> {
} }
} }
if (!smsSent && clientSmsOptIn && clientPhone) { if (!smsLog && client.smsOptIn && client.phone) {
const apiUrl = process.env.API_URL ?? "http://localhost:3000"; const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
const smsBody = [ const smsBody = [
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`, `Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`, `Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
`Confirm: ${confirmUrl}`, `Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`, `Cancel: ${cancelUrl}`,
TCPA_OPT_OUT, TCPA_OPT_OUT,
].join(". "); ].join(". ");
try { try {
const smsOk = await smsSend(clientPhone, smsBody); const smsOk = await smsSend(client.phone, smsBody);
if (smsOk) { if (smsOk) {
await db await db
.insert(reminderLogs) .insert(reminderLogs)
+2 -2
View File
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
/** /**
* Playwright configuration for GroomBook Web E2E tests. * Playwright configuration for GroomBook Web E2E tests.
* *
* Targets the deployed dev environment at dev.groombook.dev. * Targets the deployed dev environment at groombook.dev.farh.net.
* Uses the dev login selector (/login) for authentication — no hardcoded credentials. * Uses the dev login selector (/login) for authentication — no hardcoded credentials.
* *
* Run locally: * Run locally:
@@ -19,7 +19,7 @@ export default defineConfig({
reporter: process.env.CI ? "github" : "list", reporter: process.env.CI ? "github" : "list",
use: { use: {
baseURL: "https://dev.groombook.dev", baseURL: "https://groombook.dev.farh.net",
trace: "on-first-retry", trace: "on-first-retry",
screenshot: "only-on-failure", screenshot: "only-on-failure",
serviceWorkers: "block", serviceWorkers: "block",
+1 -1
View File
@@ -12,7 +12,7 @@ import { SettingsPage } from "./pages/Settings.js";
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js"; import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
import { BookingCancelledPage } from "./pages/BookingCancelled.js"; import { BookingCancelledPage } from "./pages/BookingCancelled.js";
import { BookingErrorPage } from "./pages/BookingError.js"; import { BookingErrorPage } from "./pages/BookingError.js";
import { SetupWizard } from "./pages/SetupWizard.tsx"; import { SetupWizard } from "./pages/SetupWizard.jsx";
import { CustomerPortal } from "./portal/CustomerPortal.js"; import { CustomerPortal } from "./portal/CustomerPortal.js";
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
+2 -2
View File
@@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => {
"/api/portal/appointments/appt-1/notes", "/api/portal/appointments/appt-1/notes",
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id", "Authorization": "Bearer test-session-id",
}), }),
}) })
); );
@@ -269,7 +269,7 @@ describe("ConfirmationSection", () => {
"/api/portal/appointments/appt-1/confirm", "/api/portal/appointments/appt-1/confirm",
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id", "Authorization": "Bearer test-session-id",
}), }),
}) })
); );
+3 -13
View File
@@ -26,7 +26,6 @@ export function GlobalSearch() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults | null>(null); const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -46,18 +45,15 @@ export function GlobalSearch() {
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
setLoading(true); setLoading(true);
setError(null);
try { try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
if (res.ok) { if (res.ok) {
const data: SearchResults = await res.json(); const data: SearchResults = await res.json();
setResults(data); setResults(data);
setOpen(true); setOpen(true);
} else {
setError("Search failed. Please try again.");
} }
} catch { } catch (err) {
setError("Search failed. Please try again."); console.warn("GlobalSearch: fetch error", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -164,13 +160,7 @@ export function GlobalSearch() {
</div> </div>
)} )}
{!loading && error && ( {!loading && !hasResults && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
{error}
</div>
)}
{!loading && !error && !hasResults && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}> <div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
No results found No results found
</div> </div>
@@ -71,12 +71,6 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
} }
async function handleFile(file: File) { async function handleFile(file: File) {
const MAX_FILE_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
return;
}
if (!ACCEPTED_TYPES.includes(file.type)) { if (!ACCEPTED_TYPES.includes(file.type)) {
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." }); setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
return; return;
+2 -52
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback } from "react";
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types"; import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -273,15 +273,7 @@ export function AppointmentsPage() {
cascade !== "this_only" cascade !== "this_only"
? `/api/appointments/${id}?cascade=${cascade}` ? `/api/appointments/${id}?cascade=${cascade}`
: `/api/appointments/${id}`; : `/api/appointments/${id}`;
try { await fetch(url, { method: "DELETE" });
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to delete appointment");
}
setSelectedAppt(null); setSelectedAppt(null);
await loadAppointments(); await loadAppointments();
} }
@@ -827,49 +819,8 @@ function AppointmentDetail({
} }
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement;
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
const firstFocusable = focusableElements?.[0];
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
if (!modalRef.current) return;
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
};
}, [onClose]);
return ( return (
<div <div
role="dialog"
aria-modal="true"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
@@ -882,7 +833,6 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div <div
ref={modalRef}
style={{ style={{
background: "#fff", background: "#fff",
borderRadius: 8, borderRadius: 8,
+20 -25
View File
@@ -211,41 +211,36 @@ function InvoiceDetailModal({
setSaving(true); setSaving(true);
setError(null); setError(null);
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0; const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
// Real-time validation: prevent submit if tip splits don't sum to 100%
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
if (Math.abs(totalPct - 100) >= 0.01) {
setError("Tip split percentages must sum to 100%");
setSaving(false);
return;
}
}
try { try {
const patchBody: {
status: string;
paymentMethod: string;
tipCents: number;
tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>;
} = { status: "paid", paymentMethod, tipCents };
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
patchBody.tipSplits = tipSplits.map((r) => ({
staffId: r.staffId,
staffName: r.staffName,
sharePct: r.pct,
}));
}
const res = await fetch(`/api/invoices/${invoice.id}`, { const res = await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchBody), body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
}); });
if (!res.ok) { if (!res.ok) {
const err = (await res.json()) as { error?: string }; const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`); throw new Error(err.error ?? `HTTP ${res.status}`);
} }
// Save tip splits if applicable and tip > 0
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
if (Math.abs(totalPct - 100) < 0.01) {
const splitsRes = await fetch(`/api/invoices/${invoice.id}/tip-splits`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
splits: tipSplits.map((r) => ({
staffId: r.staffId,
staffName: r.staffName,
sharePct: r.pct,
})),
}),
});
if (!splitsRes.ok) console.warn("Tip split save failed (non-blocking)");
}
}
onUpdated(); onUpdated();
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update"); setError(e instanceof Error ? e.message : "Failed to update");
+5 -5
View File
@@ -199,11 +199,11 @@ export function ReportsPage() {
} }
const [summData, revData, apptData, svcData, clientData] = await Promise.all([ const [summData, revData, apptData, svcData, clientData] = await Promise.all([
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }), summRes.json() as Promise<Summary>,
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }), revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }), apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }), svcRes.json() as Promise<{ rows: ServiceRow[] }>,
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }), clientRes.json() as Promise<ClientReport>,
]); ]);
setSummary(summData); setSummary(summData);
+4 -6
View File
@@ -27,8 +27,6 @@ interface AuthProviderForm {
const REDACTED = "••••••••"; const REDACTED = "••••••••";
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
interface CurrentUser { interface CurrentUser {
id: string; id: string;
name: string; name: string;
@@ -151,9 +149,9 @@ export function SettingsPage() {
return; return;
} }
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) { if (!validTypes.includes(file.type)) {
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." }); setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
return; return;
} }
@@ -328,7 +326,7 @@ issuerUrl: authForm.issuerUrl,
if (!loaded) return <p>Loading settings...</p>; if (!loaded) return <p>Loading settings...</p>;
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null); const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
return ( return (
<div style={{ maxWidth: 600 }}> <div style={{ maxWidth: 600 }}>
@@ -395,7 +393,7 @@ issuerUrl: authForm.issuerUrl,
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/png,image/jpeg,image/gif,image/webp" accept="image/png,image/svg+xml,image/jpeg,image/webp"
onChange={handleLogoChange} onChange={handleLogoChange}
style={{ display: "none" }} style={{ display: "none" }}
/> />
+1 -1
View File
@@ -1 +1 @@
export { SetupWizard } from "./SetupWizard.tsx"; export { SetupWizard } from "./SetupWizard.jsx";
@@ -2,39 +2,16 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useBranding } from "../BrandingContext.js"; import { useBranding } from "../BrandingContext.js";
interface SetupStatus { export function SetupWizard({ onSetupComplete }) {
showAuthProviderStep?: boolean;
}
interface TestResult {
ok: boolean;
error?: string;
}
interface AuthFormState {
providerId: string;
displayName: string;
issuerUrl: string;
internalBaseUrl: string;
clientId: string;
clientSecret: string;
scopes: string;
}
interface Step {
id: string;
title: string;
description: string;
}
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { refresh: refreshBranding } = useBranding(); const { refresh: refreshBranding } = useBranding();
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null); // Fetch setup status to determine if auth provider step is needed
const [setupStatus, setSetupStatus] = useState(null); // null = loading
const [loadingStatus, setLoadingStatus] = useState(true); const [loadingStatus, setLoadingStatus] = useState(true);
const [authForm, setAuthForm] = useState<AuthFormState>({ // Auth provider form state
const [authForm, setAuthForm] = useState({
providerId: "authentik", providerId: "authentik",
displayName: "", displayName: "",
issuerUrl: "", issuerUrl: "",
@@ -44,16 +21,16 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
scopes: "openid profile email", scopes: "openid profile email",
}); });
const [testingConnection, setTestingConnection] = useState(false); const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null); const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [businessName, setBusinessName] = useState(""); const [businessName, setBusinessName] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
fetch("/api/setup/status") fetch("/api/setup/status")
.then((r) => r.json() as Promise<SetupStatus>) .then((r) => r.json())
.then((data) => { .then((data) => {
setSetupStatus(data); setSetupStatus(data);
setLoadingStatus(false); setLoadingStatus(false);
@@ -63,7 +40,8 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
}); });
}, []); }, []);
const STEPS: Step[] = setupStatus?.showAuthProviderStep // Build steps dynamically based on setup status
const STEPS = setupStatus?.showAuthProviderStep
? [ ? [
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, { id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." }, { id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
@@ -85,8 +63,9 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
const isFirst = step === 0; const isFirst = step === 0;
const canGoBack = step > 0 && step < STEPS.length - 1; const canGoBack = step > 0 && step < STEPS.length - 1;
// Determine if we can proceed - depends on which step we're on
const canGoNext = (() => { const canGoNext = (() => {
if (step === STEPS.length - 1) return true; if (step === STEPS.length - 1) return true; // done step
if (current?.id === "business") return businessName.trim().length > 0; if (current?.id === "business") return businessName.trim().length > 0;
if (current?.id === "auth") { if (current?.id === "auth") {
return ( return (
@@ -115,9 +94,9 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
scopes: authForm.scopes, scopes: authForm.scopes,
}), }),
}); });
const data = (await res.json()) as TestResult; const data = await res.json();
setTestResult(data); setTestResult(data);
} catch { } catch (e) {
setTestResult({ ok: false, error: "Network error. Please try again." }); setTestResult({ ok: false, error: "Network error. Please try again." });
} finally { } finally {
setTestingConnection(false); setTestingConnection(false);
@@ -126,10 +105,12 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
const handleNext = async () => { const handleNext = async () => {
if (step === STEPS.length - 1) { if (step === STEPS.length - 1) {
// Done - redirect to admin
navigate("/admin"); navigate("/admin");
return; return;
} }
// Submit auth provider config
if (current?.id === "auth") { if (current?.id === "auth") {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -148,12 +129,12 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; const data = await res.json();
setError(data.error || "Failed to save auth provider configuration. Please try again."); setError(data.error || "Failed to save auth provider configuration. Please try again.");
setLoading(false); setLoading(false);
return; return;
} }
} catch { } catch (e) {
setError("Network error. Please try again."); setError("Network error. Please try again.");
setLoading(false); setLoading(false);
return; return;
@@ -161,6 +142,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
setLoading(false); setLoading(false);
} }
// Submit business name and complete setup
if (current?.id === "business" && businessName.trim()) { if (current?.id === "business" && businessName.trim()) {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -171,14 +153,16 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
body: JSON.stringify({ businessName: businessName.trim() }), body: JSON.stringify({ businessName: businessName.trim() }),
}); });
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; const data = await res.json();
setError(data.error || "Setup failed. Please try again."); setError(data.error || "Setup failed. Please try again.");
setLoading(false); setLoading(false);
return; return;
} }
// Refresh branding so the nav bar shows the new business name
refreshBranding(); refreshBranding();
// Clear needsSetup state in App so the redirect to /admin sticks
if (onSetupComplete) onSetupComplete(); if (onSetupComplete) onSetupComplete();
} catch { } catch (e) {
setError("Network error. Please try again."); setError("Network error. Please try again.");
setLoading(false); setLoading(false);
return; return;
@@ -208,7 +192,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
); );
} }
const inputStyle: React.CSSProperties = { const inputStyle = {
width: "100%", width: "100%",
padding: "0.6rem 0.85rem", padding: "0.6rem 0.85rem",
borderRadius: 8, borderRadius: 8,
@@ -236,6 +220,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
maxWidth: 480, maxWidth: 480,
width: "100%", width: "100%",
}}> }}>
{/* Progress dots */}
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}> <div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
{STEPS.map((_, i) => ( {STEPS.map((_, i) => (
<div <div
@@ -252,32 +237,38 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
))} ))}
</div> </div>
{/* Step indicator */}
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}> <p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
Step {step + 1} of {STEPS.length} Step {step + 1} of {STEPS.length}
</p> </p>
{/* Title */}
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}> <h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
{current?.title} {current?.title}
</h2> </h2>
{/* Description */}
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}> <p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
{current?.description} {current?.description}
</p> </p>
{/* Step: Business name input */}
{current?.id === "business" && ( {current?.id === "business" && (
<input <input
type="text" type="text"
placeholder="e.g. Happy Paws Grooming" placeholder="e.g. Happy Paws Grooming"
value={businessName} value={businessName}
onChange={(e) => setBusinessName(e.target.value)} onChange={(e) => setBusinessName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()} onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
autoFocus autoFocus
style={inputStyle} style={inputStyle}
/> />
)} )}
{/* Step: Auth provider config form */}
{current?.id === "auth" && ( {current?.id === "auth" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}> <div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
{/* Provider ID */}
<div> <div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Provider ID Provider ID
@@ -291,6 +282,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/> />
</div> </div>
{/* Display Name */}
<div> <div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Display Name Display Name
@@ -304,6 +296,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/> />
</div> </div>
{/* Issuer URL */}
<div> <div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Issuer URL Issuer URL
@@ -317,6 +310,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/> />
</div> </div>
{/* Internal Base URL (optional) */}
<div> <div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span> Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
@@ -330,6 +324,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/> />
</div> </div>
{/* Client ID */}
<div> <div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client ID Client ID
@@ -343,6 +338,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/> />
</div> </div>
{/* Client Secret */}
<div> <div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client Secret Client Secret
@@ -356,6 +352,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/> />
</div> </div>
{/* Scopes */}
<div> <div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Scopes Scopes
@@ -369,9 +366,10 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/> />
</div> </div>
{/* Test Connection button */}
<button <button
type="button" type="button"
onClick={() => { void handleTestConnection(); }} onClick={handleTestConnection}
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId} disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
style={{ style={{
padding: "0.45rem 0.85rem", padding: "0.45rem 0.85rem",
@@ -389,6 +387,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
{testingConnection ? "Testing..." : "Test Connection"} {testingConnection ? "Testing..." : "Test Connection"}
</button> </button>
{/* Test result */}
{testResult && ( {testResult && (
<div style={{ <div style={{
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
@@ -406,6 +405,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</div> </div>
)} )}
{/* Step: Super user info */}
{current?.id === "superuser" && ( {current?.id === "superuser" && (
<div style={{ <div style={{
background: "#f0fdf4", background: "#f0fdf4",
@@ -420,6 +420,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</div> </div>
)} )}
{/* Step: Second admin info */}
{current?.id === "admin" && ( {current?.id === "admin" && (
<div style={{ <div style={{
background: "#fffbeb", background: "#fffbeb",
@@ -433,6 +434,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</div> </div>
)} )}
{/* Error message */}
{error && ( {error && (
<p style={{ <p style={{
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
@@ -447,6 +449,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</p> </p>
)} )}
{/* Navigation buttons */}
<div style={{ <div style={{
display: "flex", display: "flex",
gap: "0.75rem", gap: "0.75rem",
@@ -473,7 +476,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</button> </button>
)} )}
<button <button
onClick={() => { void handleNext(); }} onClick={handleNext}
disabled={(!canGoNext && !isLast) || loading} disabled={(!canGoNext && !isLast) || loading}
style={{ style={{
padding: "0.55rem 1.25rem", padding: "0.55rem 1.25rem",
+3 -4
View File
@@ -16,7 +16,6 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
import { useBranding } from "../BrandingContext.js"; import { useBranding } from "../BrandingContext.js";
import { getDevUser } from "../pages/DevLoginSelector.js"; import { getDevUser } from "../pages/DevLoginSelector.js";
import type { ImpersonationSession } from "@groombook/types"; import type { ImpersonationSession } from "@groombook/types";
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
@@ -35,7 +34,7 @@ export function CustomerPortal() {
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false);
const [showReschedule, setShowReschedule] = useState(false); const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null); const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
const [session, setSession] = useState<ImpersonationSession | null>(null); const [session, setSession] = useState<ImpersonationSession | null>(null);
const [sessionExtended, setSessionExtended] = useState(false); const [sessionExtended, setSessionExtended] = useState(false);
const [clientName, setClientName] = useState<string>(""); const [clientName, setClientName] = useState<string>("");
@@ -150,7 +149,7 @@ export function CustomerPortal() {
const handleReschedule = useCallback((appointmentId: string) => { const handleReschedule = useCallback((appointmentId: string) => {
// Look up the full appointment from Dashboard's displayed data // Look up the full appointment from Dashboard's displayed data
// The appointment was already fetched by Dashboard, so we use the ID to find it // The appointment was already fetched by Dashboard, so we use the ID to find it
setRescheduleAppointment({ id: appointmentId } as PortalAppointment); setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
setShowReschedule(true); setShowReschedule(true);
}, []); }, []);
@@ -228,7 +227,7 @@ export function CustomerPortal() {
{showReschedule && rescheduleAppointment && ( {showReschedule && rescheduleAppointment && (
<RescheduleFlow <RescheduleFlow
appointment={rescheduleAppointment} appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }} onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null} sessionId={session?.id ?? null}
/> />
+7 -28
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react'; import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
export interface Appointment { interface Appointment {
id: string; id: string;
petId: string; petId: string;
serviceId: string; serviceId: string;
@@ -123,28 +123,7 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const rawAppointments: Record<string, unknown>[] = data.appointments || data || []; const fetchedAppointments: Appointment[] = data.appointments || data || [];
// Transform API response (startTime) to client format (date + time)
const fetchedAppointments: Appointment[] = rawAppointments.map((a) => {
const start = new Date(a.startTime as string);
const dateStr = start.toISOString().split('T')[0];
const hours = start.getHours();
const minutes = start.getMinutes().toString().padStart(2, '0');
const period = hours >= 12 ? 'PM' : 'AM';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const timeStr = `${hour12}:${minutes} ${period}`;
return {
...a,
date: dateStr,
time: timeStr,
petName: (a.pet as { name?: string })?.name,
serviceName: (a.service as { name?: string })?.name,
groomerName: (a.staff as { name?: string })?.name,
duration: (a.service as { duration?: number })?.duration,
price: (a.service as { price?: number })?.price,
} as Appointment;
});
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt)); const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt)); const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
@@ -400,7 +379,7 @@ export function ConfirmationSection({
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (sessionId) { if (sessionId) {
headers['X-Impersonation-Session-Id'] = sessionId ?? ''; headers['Authorization'] = `Bearer ${sessionId}`;
} }
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
method: 'POST', method: 'POST',
@@ -476,7 +455,7 @@ function CancelAppointmentButton({
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (sessionId) { if (sessionId) {
headers['X-Impersonation-Session-Id'] = sessionId ?? ''; headers['Authorization'] = `Bearer ${sessionId}`;
} }
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
method: 'POST', method: 'POST',
@@ -528,7 +507,7 @@ export function CustomerNotesSection({
try { try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (sessionId) { if (sessionId) {
headers['X-Impersonation-Session-Id'] = sessionId ?? ''; headers['Authorization'] = `Bearer ${sessionId}`;
} }
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
method: 'PATCH', method: 'PATCH',
@@ -621,7 +600,7 @@ export function RescheduleFlow({
setError(null); setError(null);
try { try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId ?? ''; if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`;
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, { const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
method: 'POST', method: 'POST',
headers, headers,
@@ -805,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Impersonation-Session-Id': sessionId ?? '', Authorization: `Bearer ${sessionId}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
petId: selectedPet.id, petId: selectedPet.id,
+1 -18
View File
@@ -116,24 +116,7 @@ export function Dashboard({
const invoicesData = await invoicesRes.json(); const invoicesData = await invoicesRes.json();
const brandingData = await brandingRes.json(); const brandingData = await brandingRes.json();
const rawAppointments: Record<string, unknown>[] = appointmentsData.appointments || []; setAppointments(appointmentsData.appointments || []);
const transformedAppointments = rawAppointments.map((a) => {
const start = new Date(a.startTime as string);
const dateStr = start.toISOString().split('T')[0];
const hours = start.getHours();
const minutes = start.getMinutes().toString().padStart(2, '0');
const period = hours >= 12 ? 'PM' : 'AM';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const timeStr = `${hour12}:${minutes} ${period}`;
return {
...a,
date: dateStr,
time: timeStr,
petName: (a.pet as { name?: string })?.name ?? '',
serviceName: (a.service as { name?: string })?.name ?? '',
};
});
setAppointments(transformedAppointments as Appointment[]);
setPets(petsData.pets || []); setPets(petsData.pets || []);
// Filter for pending invoices only (not "outstanding") // Filter for pending invoices only (not "outstanding")
+2 -19
View File
@@ -44,25 +44,8 @@ export function ReportCards({ sessionId }: Props) {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const rawAppointments: Record<string, unknown>[] = data.appointments || data || []; const allAppointments: Appointment[] = data.appointments || data || [];
const transformed: Appointment[] = rawAppointments.map((a) => { const reportCardAppointments = allAppointments.filter(
const start = new Date(a.startTime as string);
const dateStr = start.toISOString().split('T')[0];
const hours = start.getHours();
const minutes = start.getMinutes().toString().padStart(2, '0');
const period = hours >= 12 ? 'PM' : 'AM';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const timeStr = `${hour12}:${minutes} ${period}`;
return {
...a,
date: dateStr,
time: timeStr,
petName: (a.pet as { name?: string })?.name,
serviceName: (a.service as { name?: string })?.name,
groomerName: (a.staff as { name?: string })?.name,
} as Appointment;
});
const reportCardAppointments = transformed.filter(
(appt) => appt.reportCardId (appt) => appt.reportCardId
); );
setAppointments(reportCardAppointments); setAppointments(reportCardAppointments);
+4
View File
@@ -399,6 +399,7 @@ async function seedKnownUsers() {
name: adminName, name: adminName,
email: adminEmail, email: adminEmail,
oidcSub: adminEmail, oidcSub: adminEmail,
userId: adminEmail,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -425,6 +426,7 @@ async function seedKnownUsers() {
name: "UAT Super User", name: "UAT Super User",
email: "uat-super@groombook.dev", email: "uat-super@groombook.dev",
oidcSub: uatSuperOidcSub, oidcSub: uatSuperOidcSub,
userId: uatSuperOidcSub,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -451,6 +453,7 @@ async function seedKnownUsers() {
name: "UAT Staff Groomer", name: "UAT Staff Groomer",
email: "uat-groomer@groombook.dev", email: "uat-groomer@groombook.dev",
oidcSub: uatStaffOidcSub, oidcSub: uatStaffOidcSub,
userId: uatStaffOidcSub,
role: "groomer", role: "groomer",
isSuperUser: false, isSuperUser: false,
active: true, active: true,
@@ -645,6 +648,7 @@ async function seed() {
name: adminName, name: adminName,
email: adminEmail, email: adminEmail,
oidcSub: adminEmail, oidcSub: adminEmail,
userId: adminEmail,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
-2
View File
@@ -40,8 +40,6 @@ export interface Pet {
shampooPreference: string | null; shampooPreference: string | null;
specialCareNotes: string | null; specialCareNotes: string | null;
customFields: Record<string, string>; customFields: Record<string, string>;
photoKey?: string;
photoUploadedAt?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }