Compare commits

..

3 Commits

Author SHA1 Message Date
Test User a9be160c1b fix(GRO-682): pre-populate corepack cache at build time
corepack prepare now runs during Docker build (both builder and runner
stages) so the cache directory is populated before readOnlyRootFilesystem
is enforced at runtime. Previously the mkdir existed without populating
the cache, causing ENOENT errors in migrate/seed jobs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 00:35:38 +00:00
Test User dcb929be5b fix(GRO-765): remove dead upcoming/past filter code in portal appointments
The now/upcoming/past variables were unused after the response shape
change to { appointments: appts }. QA flagged them as dead code.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-17 17:22:41 +00:00
Test User 0ace23de53 fix(GRO-765): include service name in portal appointments response
- Added service JOIN to /api/portal/appointments to include name, duration,
  and price fields in the service sub-object
- Changed API response shape from { upcoming, past } to { appointments: [] }
  to match client expectations and simplify data flow
- Updated Appointments, ReportCards, and Dashboard components to transform
  the new response format (startTime → date + time) and extract serviceName,
  petName, and groomerName from nested sub-objects

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-17 12:50:07 +00:00
36 changed files with 352 additions and 1960 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ jobs:
name: Update Infra Image Tags
runs-on: ubuntu-latest
needs: [docker]
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
pull-requests: write
-13
View File
@@ -8,16 +8,3 @@ dist/
.turbo/
coverage/
minimax-output/
# Agent runtime artifacts — never commit
.gh-token
*.gh-token
.config/gh/
**/.config/gh/
infra-repo
infra-repo/
**/instructions/.gh-token
**/AGENT_HOME/**
$AGENT_HOME/**
.claude/
.codex/
+5 -2
View File
@@ -12,7 +12,8 @@ RUN pnpm install --frozen-lockfile
# Build
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 apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \
@@ -21,7 +22,9 @@ RUN pnpm --filter @groombook/types build && \
# Runtime
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
RUN corepack enable && \
mkdir -p /home/node/.cache/node/corepack && \
corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
ENV NODE_ENV=production
-63
View File
@@ -1,63 +0,0 @@
# GroomBook API — UAT Playbook
This document captures user-acceptance test cases for GroomBook API features. Each section corresponds to a feature or bug-fix PR. Update this file when a PR changes user-facing behaviour.
---
## 1. Appointment Booking (`/api/appointments`)
### 1.1 Create Appointment
- [ ] POST `/api/appointments` with valid payload → 201, appointment returned with generated id
- [ ] Overlapping staff appointment → 409 conflict error returned
- [ ] `endTime` before `startTime` → 422 error
### 1.2 Update Appointment (PATCH `/api/appointments/:id`)
- [ ] Extending `endTime` on a `scheduled` appointment triggers cascade delay prevention if downstream appointments exist
- [ ] Extending `endTime` returns `cascade` object in response with shifted appointments
- [ ] Extending `endTime` sends reschedule email to each affected client
- [ ] Appointments outside business hours after shift are flagged in `cascade.flaggedForReview` instead of auto-shifted
- [ ] Only `scheduled` and `confirmed` downstream appointments are shifted; `in_progress`, `completed`, `cancelled` are skipped
- [ ] Cascade stops when a downstream appointment no longer conflicts with the shifted boundary
- [ ] Shifts are included in API response under `cascade.shifted[]`
### 1.3 Series (Recurring) Appointments
- [ ] Updating one occurrence with `cascadeMode: "this_and_future"` shifts that occurrence and all future ones
- [ ] Updating one occurrence with `cascadeMode: "all"` shifts every occurrence in the series
---
## 2. Cascade Delay Prevention
### 2.1 Basic Cascade
- [ ] When a groomer's appointment overruns, the next same-groomer `scheduled` appointment shifts forward
- [ ] Delta applied to both `startTime` and `endTime` (duration preserved)
- [ ] Cascade propagates through multiple downstream appointments
### 2.2 Buffer Time
- [ ] A configurable buffer (default 15 minutes) is added between the overrunning appointment end and the shifted start
- [ ] Cascade respects the buffer between each consecutive pair of appointments
### 2.3 Business Hours Guard
- [ ] If a proposed shift would place an appointment start or end outside business hours, it is flagged instead of shifted
- [ ] Flagged appointments are listed in `cascade.flaggedForReview[]` with reason text
### 2.4 Email Notification
- [ ] Each shifted appointment triggers a reschedule email to the client
- [ ] Email includes original time (struck through) and new time
- [ ] Email is skipped silently if SMTP is not configured
### 2.5 Status Transition Overrun
- [ ] When an `in_progress` appointment's actual end time exceeds `endTime + bufferMinutes`, the cascade is triggered using the status transition path
---
## 3. Authentication & RBAC
### 3.1 Staff Authentication
- [ ] Unauthenticated request → 401
- [ ] Groomer role can only view/edit their own appointments → 403 for others
- [ ] Manager role can view/edit all appointments
### 3.2 Client Authentication
- [ ] Clients can access their own appointments via tokenized links
- [ ] Tokenized confirm/cancel links work without authentication
-373
View File
@@ -1,373 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cascadeDelay } from "../cascade.js";
// ─── Mock the DB ───────────────────────────────────────────────────────────────
const mockDb = {
select: vi.fn(),
update: vi.fn(),
};
vi.mock("@groombook/db", () => ({
getDb: () => mockDb,
appointments: {
id: Symbol("id"),
staffId: Symbol("staffId"),
startTime: Symbol("startTime"),
endTime: Symbol("endTime"),
status: Symbol("status"),
},
clients: { id: Symbol("id"), name: Symbol("name"), email: Symbol("email") },
pets: { id: Symbol("id"), name: Symbol("name") },
services: { id: Symbol("id"), name: Symbol("name") },
staff: { id: Symbol("id"), name: Symbol("name") },
eq: (a: symbol, b: unknown) => ({ type: "eq", a, b }),
and: (...args: unknown[]) => ({ type: "and", args }),
gt: (a: symbol, b: unknown) => ({ type: "gt", a, b }),
inArray: (a: symbol, vals: unknown[]) => ({ type: "inArray", a, vals }),
asc: (a: symbol) => ({ type: "asc", a }),
}));
vi.mock("../services/email.js", () => ({
sendEmail: vi.fn().mockResolvedValue(true),
}));
const { sendEmail } = await import("../services/email.js");
const { getDb } = await import("@groombook/db");
// ─── Helpers ────────────────────────────────────────────────────────────────────
function makeAppt(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "appt-1",
staffId: "groomer-1",
startTime: new Date("2026-05-16T10:00:00Z"),
endTime: new Date("2026-05-16T11:00:00Z"),
status: "scheduled",
clientId: "client-1",
petId: "pet-1",
serviceId: "svc-1",
...overrides,
};
}
function makeEnrichedAppt(id: string, start: Date, end: Date) {
return {
id,
originalStartTime: start,
originalEndTime: end,
newStartTime: start,
newEndTime: end,
clientId: "client-1",
clientName: "Alice Smith",
clientEmail: "alice@example.com",
petName: "Buddy",
serviceName: "Full Groom",
groomerName: "Jamie",
};
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("cascadeDelay", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns early when the triggering appointment is not found", async () => {
mockDb.select.mockResolvedValueOnce([]);
const result = await cascadeDelay(
"nonexistent",
new Date("2026-05-16T12:00:00Z"),
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
expect(result.flaggedForReview).toHaveLength(0);
});
it("returns early when the appointment has no groomer assigned", async () => {
mockDb.select.mockResolvedValueOnce([{ ...makeAppt(), staffId: null }]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T12:00:00Z"),
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
});
it("returns early when newEndTime does not extend beyond originalEndTime", async () => {
mockDb.select.mockResolvedValueOnce([makeAppt()]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"), // earlier than original 11:00
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
});
it("returns early when there are no downstream appointments", async () => {
mockDb.select
.mockResolvedValueOnce([makeAppt()]) // triggering appt
.mockResolvedValueOnce([]); // no downstream
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z")
);
expect(result.shifted).toHaveLength(0);
});
it("shifts a single downstream appointment by the correct delta", async () => {
const triggerEnd = new Date("2026-05-16T11:30:00Z"); // 30 min overrun
const originalEnd = new Date("2026-05-16T11:00:00Z");
const downstreamStart = new Date("2026-05-16T11:00:00Z");
const downstreamEnd = new Date("2026-05-16T12:00:00Z");
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({
id: "downstream-1",
startTime: downstreamStart,
endTime: downstreamEnd,
status: "scheduled",
}),
]);
const updateMock = mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "client-1",
clientName: "Alice",
clientEmail: "alice@example.com",
petName: "Buddy",
serviceName: "Full Groom",
groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
triggerEnd,
originalEnd,
15 // 15 min buffer
);
// effectiveBoundary = 11:30 + 15min = 11:45
// delta = 11:45 - 11:00 = 45 min = 2_700_000 ms
const expectedDeltaMs = 45 * 60 * 1000;
expect(result.shifted).toHaveLength(1);
expect(result.shifted[0].id).toBe("downstream-1");
expect(result.shifted[0].newStartTime.getTime() - result.shifted[0].originalStartTime.getTime())
.toBe(expectedDeltaMs);
expect(sendEmail).toHaveBeenCalledTimes(1);
});
it("cascades shifts through a chain of appointments", async () => {
const triggerEnd = new Date("2026-05-16T12:00:00Z"); // 60 min overrun
const originalEnd = new Date("2026-05-16T11:00:00Z");
// Three downstream appointments, each 1 hour
const appt1Start = new Date("2026-05-16T11:00:00Z");
const appt1End = new Date("2026-05-16T12:00:00Z");
const appt2Start = new Date("2026-05-16T12:00:00Z");
const appt2End = new Date("2026-05-16T13:00:00Z");
const appt3Start = new Date("2026-05-16T13:00:00Z");
const appt3End = new Date("2026-05-16T14:00:00Z");
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({ id: "appt-2", startTime: appt2Start, endTime: appt2End, status: "confirmed" }),
makeAppt({ id: "appt-3", startTime: appt3Start, endTime: appt3End, status: "scheduled" }),
]);
mockDb.update.mockReturnValueThis();
// Two enrich queries for the two shifted appointments
mockDb.select
.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
])
.mockResolvedValueOnce([
{
clientId: "c2", clientName: "Bob", clientEmail: "bob@test.com",
petName: "Max", serviceName: "Bath", groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
triggerEnd,
originalEnd,
15
);
// effectiveBoundary starts at 12:00 + 15 = 12:15
// appt-2: 12:00 start conflicts with 12:15 boundary → shift by 15 min → starts 12:15, ends 13:15
// new boundary: 13:15 + 15 = 13:30
// appt-3: 13:00 start conflicts with 13:30 boundary → shift by 30 min → starts 13:30, ends 14:30
expect(result.shifted).toHaveLength(2);
expect(result.shifted[0].id).toBe("appt-2");
expect(result.shifted[1].id).toBe("appt-3");
expect(mockDb.update).toHaveBeenCalledTimes(2);
expect(sendEmail).toHaveBeenCalledTimes(2);
});
it("flags but still updates boundary when shift would fall outside business hours", async () => {
const triggerEnd = new Date("2026-05-16T17:00:00Z");
const originalEnd = new Date("2026-05-16T16:00:00Z");
// Downstream appt starts at 16:00, business ends at 18:00
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({
id: "appt-late",
startTime: new Date("2026-05-16T16:00:00Z"),
endTime: new Date("2026-05-16T17:00:00Z"),
status: "scheduled",
}),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
]);
// Business hours 08:0018:00; proposed shift pushes to 17:15 start (still in hours)
// Try a late-night boundary: shift would push to 19:15 (outside 08:0018:00)
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T18:00:00Z"), // larger overrun
originalEnd,
15,
8, // business start
18 // business end — proposed 18:15 start is outside
);
// The appointment at 16:00 with buffer of 15 min after 18:00 trigger:
// effectiveBoundary = 18:00 + 15 = 18:15 → outside business hours (18:15 > 18:00)
expect(result.flaggedForReview).toHaveLength(1);
expect(result.flaggedForReview[0].id).toBe("appt-late");
expect(result.flaggedForReview[0].reason).toContain("Manual review required");
// The appointment was NOT shifted (only flagged)
expect(mockDb.update).not.toHaveBeenCalled();
});
it("skips non-active appointments", async () => {
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({ id: "in-progress-1", status: "in_progress" }),
makeAppt({ id: "cancelled-1", status: "cancelled" }),
makeAppt({ id: "scheduled-1", status: "scheduled" }),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z"),
15
);
// Only the scheduled appointment should be shifted
expect(result.shifted).toHaveLength(1);
expect(result.shifted[0].id).toBe("scheduled-1");
});
it("stops cascading when an appointment no longer conflicts", async () => {
// Three downstream: appt-2 overlaps, appt-3 does NOT overlap, appt-4 overlaps
// Cascade should stop at appt-3
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
// appt-2: starts at 11:00, ends 12:00 — overlaps boundary 11:45
makeAppt({ id: "appt-2", startTime: new Date("2026-05-16T11:00:00Z"), endTime: new Date("2026-05-16T12:00:00Z") }),
// appt-3: starts at 13:00 — already clear of shifted appt-2 (ends 12:15 + buffer)
makeAppt({ id: "appt-3", startTime: new Date("2026-05-16T13:00:00Z"), endTime: new Date("2026-05-16T14:00:00Z") }),
makeAppt({ id: "appt-4", startTime: new Date("2026-05-16T14:00:00Z"), endTime: new Date("2026-05-16T15:00:00Z") }),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
},
]);
const result = await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z"),
15
);
// Only appt-2 was shifted (appt-3 no longer conflicts after the stop condition check)
expect(result.shifted).toHaveLength(1);
expect(result.shifted[0].id).toBe("appt-2");
});
it("sends email notification for each shifted appointment", async () => {
mockDb.select
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
.mockResolvedValueOnce([
makeAppt({
id: "appt-email-test",
startTime: new Date("2026-05-16T11:00:00Z"),
endTime: new Date("2026-05-16T12:00:00Z"),
status: "confirmed",
}),
]);
mockDb.update.mockReturnValueThis();
mockDb.select.mockResolvedValueOnce([
{
clientId: "c1",
clientName: "Carol",
clientEmail: "carol@example.com",
petName: "Luna",
serviceName: "Nail Trim",
groomerName: null,
},
]);
await cascadeDelay(
"appt-trigger",
new Date("2026-05-16T11:30:00Z"),
new Date("2026-05-16T11:00:00Z"),
15
);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: "carol@example.com",
subject: expect.stringContaining("Rescheduled"),
})
);
});
});
+10 -21
View File
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js";
import { getObject } from "./lib/s3.js";
import { getPresignedGetUrl } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db";
@@ -126,31 +126,20 @@ function validateLogoMagicBytes(
}
}
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
app.get("/api/branding/logo", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
// Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
// Return the public proxy path so browser never sees a raw S3 URL
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
let logoUrl: string | null = null;
if (settings.logoKey) {
try {
logoUrl = await getPresignedGetUrl(settings.logoKey);
} catch {
// If S3 URL generation fails, fall back to legacy base64
}
}
// Defensive: validate magic bytes to prevent MIME type confusion attacks
// via the legacy base64 logo fields
@@ -213,7 +202,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer"));
api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
+4 -10
View File
@@ -93,12 +93,9 @@ export async function initAuth(): Promise<void> {
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
max: 10,
window: 60,
storage: "memory",
customRules: {
"/get-session": false,
},
},
plugins: [
genericOAuth({
@@ -243,12 +240,9 @@ export async function initAuth(): Promise<void> {
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
max: 10,
window: 60,
storage: "memory",
customRules: {
"/get-session": false,
},
},
account: {
storeStateStrategy: "cookie" as const,
-341
View File
@@ -1,341 +0,0 @@
/**
* Cascade delay prevention — `apps/api/src/lib/cascade.ts`
*
* Triggered after a PATCH /appointments/:id call extends an appointment's
* endTime beyond its original value. Queries same-groomer downstream
* appointments, shifts them forward by (overrunEnd + buffer downstreamStart),
* and cascades the shift through the chain. Clients are notified by email.
*
* Guard rails:
* - Only shifts `scheduled` and `confirmed` appointments.
* - Flags out-of-business-hours shifts for manual review instead of auto-shifting.
* - Returns the full list of shifted appointments.
*/
import { eq, and, gt, lte, asc, ne, inArray } from "drizzle-orm";
import { getDb, appointments, clients, pets, services, staff } from "@groombook/db";
import { sendEmail } from "../services/email.js";
// ─── Types ──────────────────────────────────────────────────────────────────────
export interface CascadeResult {
shifted: ShiftedAppointment[];
flaggedForReview: FlaggedAppointment[];
/** Time in ms each downstream appointment was pushed forward */
cascadeLog: CascadeLogEntry[];
}
export interface ShiftedAppointment {
id: string;
originalStartTime: Date;
originalEndTime: Date;
newStartTime: Date;
newEndTime: Date;
clientId: string;
clientName: string;
clientEmail: string;
petName: string;
serviceName: string;
groomerName: string | null;
}
export interface FlaggedAppointment {
id: string;
originalStartTime: Date;
proposedStartTime: Date;
proposedEndTime: Date;
reason: string;
}
export interface CascadeLogEntry {
appointmentId: string;
deltaMs: number;
triggeredBy: string;
}
// ─── Config ───────────────────────────────────────────────────────────────────
/** Default inter-appointment buffer in minutes. Overridden by services.bufferMinutes. */
export const DEFAULT_BUFFER_MINUTES = 15;
/** Default business hours (used when no settings row exists). */
export const DEFAULT_BUSINESS_START_HOUR = 8; // 08:00
export const DEFAULT_BUSINESS_END_HOUR = 18; // 18:00
// ─── Core cascade ───────────────────────────────────────────────────────────────
/**
* Detect and cascade appointment overruns.
*
* @param triggeringAppointmentId The appointment that just overran.
* @param newEndTime The updated endTime set by the caller.
* @param originalEndTime The appointment's endTime before the update.
* @param bufferMinutes Minutes of buffer between appointments (default 15).
* @param businessStartHour Business opening hour (023, default 8).
* @param businessEndHour Business closing hour (023, default 18).
*/
export async function cascadeDelay(
triggeringAppointmentId: string,
newEndTime: Date,
originalEndTime: Date,
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
): Promise<CascadeResult> {
const db = getDb();
const bufferMs = bufferMinutes * 60_000;
const overrunEnd = newEndTime;
// ── 1. Load the triggering appointment ────────────────────────────────────────
const [triggering] = await db
.select()
.from(appointments)
.where(eq(appointments.id, triggeringAppointmentId))
.limit(1);
if (!triggering) {
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
if (!triggering.staffId) {
// Unassigned appointments cannot cascade
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
const groomerId = triggering.staffId;
// ── 2. Guard: only trigger when endTime actually extended ──────────────────────
if (overrunEnd <= originalEndTime) {
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
const result: CascadeResult = { shifted: [], flaggedForReview: [], cascadeLog: [] };
// ── 3. Fetch all downstream same-groomer active appointments ──────────────────
const downstream = await db
.select()
.from(appointments)
.where(
and(
eq(appointments.staffId, groomerId),
gt(appointments.startTime, originalEndTime),
inArray(appointments.status, ["scheduled", "confirmed"]),
)
)
.orderBy(asc(appointments.startTime));
if (downstream.length === 0) return result;
// ── 4. Cascade loop ────────────────────────────────────────────────────────────
// Keep track of current effective boundary after each shift.
// Start from the new endTime of the triggering appointment plus buffer.
let effectiveBoundary = new Date(overrunEnd.getTime() + bufferMs);
for (const appt of downstream) {
const conflictStart = appt.startTime;
const conflictEnd = appt.endTime;
const apptDurationMs = conflictEnd.getTime() - conflictStart.getTime();
// Does this appointment overlap the effective boundary?
if (effectiveBoundary.getTime() >= conflictEnd.getTime()) {
// No conflict — this appointment and all later ones are unaffected
break;
}
const proposedStart = new Date(effectiveBoundary);
const proposedEnd = new Date(proposedStart.getTime() + apptDurationMs);
// ── Business-hours guard ────────────────────────────────────────────────────
const proposedStartHour = proposedStart.getHours() + proposedStart.getMinutes() / 60;
const proposedEndHour = proposedEnd.getHours() + proposedEnd.getMinutes() / 60;
const outOfHours =
proposedStartHour < businessStartHour ||
proposedEndHour > businessEndHour;
if (outOfHours) {
result.flaggedForReview.push({
id: appt.id,
originalStartTime: appt.startTime,
proposedStartTime: proposedStart,
proposedEndTime: proposedEnd,
reason:
`Would push appointment outside business hours ` +
`(${businessStartHour}:00${businessEndHour}:00). ` +
`Manual review required.`,
});
// Update boundary anyway — later appointments may still conflict
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
continue;
}
// ── Perform the shift ──────────────────────────────────────────────────────
const deltaMs = proposedStart.getTime() - appt.startTime.getTime();
await db
.update(appointments)
.set({ startTime: proposedStart, endTime: proposedEnd, updatedAt: new Date() })
.where(eq(appointments.id, appt.id));
result.cascadeLog.push({
appointmentId: appt.id,
deltaMs,
triggeredBy: triggeringAppointmentId,
});
// ── Load client/pet/service info for notification ──────────────────────────
const enriched = await enrichAppointment(appt.id);
if (enriched) {
result.shifted.push({
id: appt.id,
originalStartTime: appt.startTime,
originalEndTime: appt.endTime,
newStartTime: proposedStart,
newEndTime: proposedEnd,
...enriched,
});
}
// Advance boundary to the end of this shifted appointment plus buffer
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
}
// ── 5. Send notifications ────────────────────────────────────────────────────
for (const shifted of result.shifted) {
await sendRescheduleNotification(shifted).catch((err) =>
console.error(`[cascade] Failed to send notification for ${shifted.id}:`, err)
);
}
return result;
}
/**
* Shortcut for status-transition overruns (current time > endTime + bufferMinutes).
* Delegates to `cascadeDelay` using the current appointment data.
*/
export async function cascadeOnStatusOverrun(
appointmentId: string,
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
): Promise<CascadeResult> {
const db = getDb();
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.id, appointmentId))
.limit(1);
if (!appt) return { shifted: [], flaggedForReview: [], cascadeLog: [] };
const now = new Date();
const bufferMs = bufferMinutes * 60_000;
if (now.getTime() <= appt.endTime.getTime() + bufferMs) {
// Not actually in overrun
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
}
// Use current time as the new endTime (the appointment is already running over)
return cascadeDelay(
appointmentId,
now,
appt.endTime,
bufferMinutes,
businessStartHour,
businessEndHour
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────────
interface EnrichedFields {
clientId: string;
clientName: string;
clientEmail: string;
petName: string;
serviceName: string;
groomerName: string | null;
}
async function enrichAppointment(
apptId: string
): Promise<EnrichedFields | null> {
const db = getDb();
const [row] = await db
.select({
clientId: appointments.clientId,
clientName: clients.name,
clientEmail: clients.email,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, apptId))
.limit(1);
if (!row) return null;
return {
clientId: row.clientId,
clientName: row.clientName,
clientEmail: row.clientEmail,
petName: row.petName,
serviceName: row.serviceName,
groomerName: row.groomerName,
};
}
async function sendRescheduleNotification(
shifted: ShiftedAppointment
): Promise<void> {
const time = formatDateTime(shifted.newStartTime);
const original = formatDateTime(shifted.originalStartTime);
const groomer = shifted.groomerName ? ` with ${shifted.groomerName}` : "";
await sendEmail({
to: shifted.clientEmail,
subject: `Appointment Rescheduled — ${shifted.petName}`,
text: [
`Hi ${shifted.clientName},`,
``,
`Your appointment for ${shifted.petName} has been rescheduled.`,
``,
` Was: ${original}${groomer}`,
` Now: ${time}${groomer}`,
``,
`We apologize for any inconvenience. If this new time doesn't work for you, please contact us as soon as possible.`,
``,
`— Groom Book`,
].join("\n"),
html: `<p>Hi ${shifted.clientName},</p>
<p>Your appointment for <strong>${shifted.petName}</strong> has been rescheduled.</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Previous time</td><td style="text-decoration:line-through;color:#9ca3af">${original}${groomer}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">New time</td><td>${time}${groomer}</td></tr>
</table>
<p>If this new time doesn't work for you, please contact us as soon as possible.</p>
<p>— Groom Book</p>`,
});
console.info(
`[cascade] Notified ${shifted.clientEmail} of reschedule for ${shifted.petName} ` +
`(${shifted.id}): ${original}${time}`
);
}
function formatDateTime(d: Date): string {
return d.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
-38
View File
@@ -67,41 +67,3 @@ export async function deleteObject(key: string): Promise<void> {
})
);
}
/** Read an object from S3 and return its body buffer and content type. */
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
const client = getS3Client();
const response = await client.send(
new GetObjectCommand({
Bucket: getBucket(),
Key: key,
})
);
const chunks: Uint8Array[] = [];
// response.Body is a Readable stream; collect chunks into a buffer
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const contentType = response.ContentType ?? "application/octet-stream";
return { body, contentType };
}
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
export async function putObject(
key: string,
body: Buffer | Uint8Array | string,
contentType: string,
contentLength: number
): Promise<void> {
const client = getS3Client();
await client.send(
new PutObjectCommand({
Bucket: getBucket(),
Key: key,
Body: body,
ContentType: contentType,
ContentLength: contentLength,
})
);
}
+1 -27
View File
@@ -20,7 +20,6 @@ import {
staff,
} from "@groombook/db";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { cascadeDelay } from "../lib/cascade.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js";
@@ -581,15 +580,6 @@ appointmentsRouter.patch(
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
if (needsConflictCheck) {
// Capture original endTime before the transaction so we can detect an
// overrun and trigger cascade delay prevention after the update.
const [preUpdate] = await db
.select({ originalEndTime: appointments.endTime })
.from(appointments)
.where(eq(appointments.id, id))
.limit(1);
const originalEndTime = preUpdate?.originalEndTime ?? null;
// Wrap conflict check + update in a transaction to prevent race conditions
// (fixes #18). Also falls back to the existing staffId when staffId is
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
@@ -694,23 +684,7 @@ appointmentsRouter.patch(
}
if (!row) return c.json({ error: "Not found" }, 404);
// Cascade delay prevention: detect if endTime was extended and cascade
// downstream appointments if so. Runs after the main update commits.
const cascadeResult =
updateFields.endTime &&
originalEndTime &&
new Date(updateFields.endTime) > originalEndTime
? await cascadeDelay(id, new Date(updateFields.endTime), originalEndTime)
: { shifted: [], flaggedForReview: [], cascadeLog: [] };
return c.json({
...row,
cascade:
cascadeResult.shifted.length > 0 || cascadeResult.flaggedForReview.length > 0
? cascadeResult
: undefined,
});
return c.json(row);
}
const [row] = await db
+63 -152
View File
@@ -18,14 +18,6 @@ import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>();
// Convert Zod validation errors from 422 to 400
invoicesRouter.onError((err, c) => {
if (err instanceof z.ZodError) {
return c.json({ error: "Validation failed", issues: err.issues }, 400);
}
throw err;
});
const createInvoiceSchema = z.object({
appointmentId: z.string().uuid().optional(),
clientId: z.string().uuid(),
@@ -101,8 +93,6 @@ invoicesRouter.get(
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
@@ -130,17 +120,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
let cardLast4: string | null = null;
let paymentStatus: string | null = null;
if (invoice.stripePaymentIntentId) {
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
if (details) {
cardLast4 = details.cardLast4;
paymentStatus = details.paymentStatus;
}
}
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
return c.json({ ...invoice, lineItems, tipSplits });
});
// Save tip splits for an invoice (replaces existing splits)
@@ -361,23 +341,30 @@ invoicesRouter.patch(
}
}
const tipCents = body.tipCents ?? current.tipCents;
// Validate tip splits when marking invoice as paid
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
if (body.tipSplits.length === 0) {
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
}
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
if (Math.abs(totalPct - 100) > 0.01) {
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
// Tip split validation when marking as paid with a tip
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);
}
}
}
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body;
const update: Record<string, unknown> = { ...bodyWithoutSplits, updatedAt: new Date() };
// Auto-set paidAt when marking as paid
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
@@ -391,50 +378,54 @@ invoicesRouter.patch(
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
}
// Wrap tip split persistence and invoice update in a single atomic transaction
const [updated, lineItems] = await db.transaction(async (tx) => {
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
const splits = body.tipSplits;
if (splits.length > 0) {
let remaining = tipCents;
const rows = splits.map((s, i) => {
const isLast = i === splits.length - 1;
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
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);
}
}
const [updatedInvoice] = await tx
const [updated] = await db.transaction(async (tx) => {
const [upd] = await tx
.update(invoices)
.set(update)
.where(eq(invoices.id, id))
.returning();
const lineItems = await tx
.select()
.from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, id));
// 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));
return [updatedInvoice, lineItems];
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
.select()
.from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, id));
return c.json({ ...updated, lineItems });
}
);
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
@@ -460,6 +451,9 @@ invoicesRouter.post(
if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
}
return await db.transaction(async (tx) => {
if (body.idempotencyKey) {
@@ -472,100 +466,17 @@ invoicesRouter.post(
}
}
let refundId: string;
if (invoice.stripePaymentIntentId) {
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
refundId = result.refundId;
} else {
// Manual refund — no Stripe call needed
refundId = `manual_${id}_${Date.now()}`;
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: refundId,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId });
return c.json({ refundId: result.refundId });
});
}
);
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", async (c) => {
try {
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "pending"));
const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db
.select({
method: invoices.paymentMethod,
total: sql<number>`count(*)`,
})
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod);
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown,
});
} catch (err) {
console.error("stats/summary error:", err);
return c.json({
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
});
}
});
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
invoicesRouter.get("/:id/stripe-details", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
let cardLast4: string | null = null;
let paymentStatus: string | null = null;
if (invoice.stripePaymentIntentId) {
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
if (details) {
cardLast4 = details.cardLast4;
paymentStatus = details.paymentStatus;
}
}
return c.json({
stripePaymentIntentId: invoice.stripePaymentIntentId,
stripeRefundId: invoice.stripeRefundId,
cardLast4,
paymentStatus,
});
});
+2 -10
View File
@@ -213,11 +213,7 @@ petsRouter.post(
// Delete the previous photo from storage to avoid orphaned objects
if (pet.photoKey) {
try {
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
}
await deleteObject(pet.photoKey);
}
const [row] = await db
@@ -244,11 +240,7 @@ petsRouter.delete("/:petId/photo", async (c) => {
if (!pet) return c.json({ error: "Pet not found" }, 404);
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
try {
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
}
await deleteObject(pet.photoKey);
await db
.update(pets)
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
+75 -65
View File
@@ -9,68 +9,6 @@ import type { PortalEnv } from "../middleware/portalSession.js";
export const portalRouter = new Hono<PortalEnv>();
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
// the impersonation session and has no X-Impersonation-Session-Id header yet.
const devSessionSchema = z.object({
clientId: z.string().uuid(),
});
portalRouter.post(
"/dev-session",
zValidator("json", devSessionSchema),
async (c) => {
if (process.env.AUTH_DISABLED !== "true") {
return c.json({ error: "Not available when auth is enabled" }, 403);
}
const db = getDb();
const body = c.req.valid("json");
const [client] = await db
.select()
.from(clients)
.where(eq(clients.id, body.clientId))
.limit(1);
if (!client) {
return c.json({ error: "Client not found" }, 404);
}
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.id, DEMO_STAFF_ID))
.limit(1);
if (!demoStaff) {
const [firstStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.active, true))
.limit(1);
if (!firstStaff) {
return c.json({ error: "No staff records found. Run the database seed." }, 500);
}
staffId = firstStaff.id;
}
const [session] = await db
.insert(impersonationSessions)
.values({
staffId,
clientId: body.clientId,
reason: "dev-mode-client-portal",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})
.returning();
return c.json(session, 201);
}
);
// Apply middleware to all portal routes
portalRouter.use("/*", validatePortalSession, portalAudit);
@@ -121,12 +59,15 @@ portalRouter.get("/appointments", async (c) => {
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 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 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 staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({
id: a.id,
@@ -137,7 +78,7 @@ portalRouter.get("/appointments", async (c) => {
customerNotes: a.customerNotes,
notes: a.notes,
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 } : null,
service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name, duration: serviceMap[a.serviceId]?.durationMinutes, price: serviceMap[a.serviceId]?.basePriceCents } : null,
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
@@ -149,7 +90,7 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId");
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, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: 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 })));
});
portalRouter.get("/invoices", async (c) => {
@@ -518,4 +459,73 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
const ok = await detachPaymentMethod(paymentMethodId);
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
return c.json({ ok: true });
});
});
// ─── Dev-mode session creation ──────────────────────────────────────────────
// Allows the dev login selector to vend an impersonation session for a client
// without requiring manager auth. Only available when AUTH_DISABLED=true.
const devSessionSchema = z.object({
clientId: z.string().uuid(),
});
portalRouter.post(
"/dev-session",
zValidator("json", devSessionSchema),
async (c) => {
if (process.env.AUTH_DISABLED !== "true") {
return c.json({ error: "Not available when auth is enabled" }, 403);
}
const db = getDb();
const body = c.req.valid("json");
// Verify client exists
const [client] = await db
.select()
.from(clients)
.where(eq(clients.id, body.clientId))
.limit(1);
if (!client) {
return c.json({ error: "Client not found" }, 404);
}
// Find a staff record to associate with the dev impersonation session.
// Use the demo-manager if it exists (created by seed with known ID),
// otherwise fall back to the first active staff record.
// This avoids hardcoding a UUID that may not exist in all environments.
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.id, DEMO_STAFF_ID))
.limit(1);
if (!demoStaff) {
// Fall back to any active staff member
const [firstStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.active, true))
.limit(1);
if (!firstStaff) {
return c.json({ error: "No staff records found. Run the database seed." }, 500);
}
staffId = firstStaff.id;
}
const [session] = await db
.insert(impersonationSessions)
.values({
staffId,
clientId: body.clientId,
reason: "dev-mode-client-portal",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
})
.returning();
return c.json(session, 201);
}
);
+4 -82
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db";
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
@@ -100,77 +100,6 @@ settingsRouter.post(
}
);
/**
* POST /api/admin/settings/logo/upload
* Proxy upload through the API server to avoid mixed-content issues with
* pre-signed URLs that use the internal HTTP endpoint. The file is uploaded
* directly to S3 from the server using the internal endpoint.
*/
settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => {
const db = getDb();
// Parse multipart form data (file field)
const body = await c.req.parseBody({ all: true });
const file = body["file"];
if (!file || !(file instanceof File)) {
return c.json({ error: "No file provided" }, 400);
}
const contentType = file.type;
if (!ALLOWED_LOGO_TYPES.has(contentType)) {
return c.json(
{
error:
"contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
},
400
);
}
const fileSizeBytes = file.size;
if (fileSizeBytes > MAX_LOGO_SIZE) {
return c.json({ error: "File must not exceed 512 KB" }, 400);
}
const rows = await db.select().from(businessSettings).limit(1);
if (!rows[0]) {
return c.json({ error: "Settings not found" }, 404);
}
const settingsId = rows[0].id;
const ext = contentType.split("/")[1] ?? "png";
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
// Read file into buffer and upload directly to S3 (bypasses pre-signed URL)
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await putObject(key, buffer, contentType, fileSizeBytes);
// Delete previous S3 object if any
if (rows[0].logoKey) {
await deleteObject(rows[0].logoKey);
}
// Update database with new logo key
const [updated] = await db
.update(businessSettings)
.set({
logoKey: key,
logoBase64: null,
logoMimeType: null,
updatedAt: new Date(),
})
.where(eq(businessSettings.id, settingsId))
.returning();
if (!updated) {
return c.json({ error: "Settings not found" }, 404);
}
return c.json({ ok: true, logoKey: updated.logoKey });
});
/**
* POST /api/admin/settings/logo/confirm
* Called after the client has successfully uploaded to the presigned URL.
@@ -215,8 +144,7 @@ settingsRouter.post(
/**
* GET /api/admin/settings/logo
* Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type.
* Returns a presigned GET URL for the logo.
*/
settingsRouter.get("/logo", async (c) => {
const db = getDb();
@@ -225,14 +153,8 @@ settingsRouter.get("/logo", async (c) => {
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
const url = await getPresignedGetUrl(row.logoKey);
return c.json({ url, logoKey: row.logoKey });
});
/**
+1 -1
View File
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
const entry = rateLimitMap.get(ip);
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
-16
View File
@@ -162,19 +162,3 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
return { clientSecret: setupIntent.client_secret! };
}
export async function getPaymentIntentDetails(
paymentIntentId: string
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
const cardLast4 = pi.payment_method
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
: null;
return {
cardLast4,
paymentStatus: pi.status ?? null,
};
}
-49
View File
@@ -63,52 +63,3 @@ test("clicking a client shows their details", async ({ page }) => {
// Email appears in both the list row and the detail panel once selected
await expect(page.getByText("alice@example.com")).toHaveCount(2);
});
test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => {
// Mock individual client fetch for direct navigation
await page.route("/api/clients/client-1", (route) =>
route.fulfill({ json: MOCK_CLIENTS[0] })
);
// Mock pets for this client
await page.route("/api/pets**", (route) =>
route.fulfill({ json: [] })
);
await page.goto("/admin/clients/client-1");
// Client name must be visible without any clicking
await expect(page.getByText("Alice Johnson")).toBeVisible();
// Should show back to list link
await expect(page.getByText("← Back to list")).toBeVisible();
});
test("direct URL navigation shows loading then client", async ({ page }) => {
let resolvePets: (value: unknown) => void;
const petsPromise = new Promise((resolve) => { resolvePets = resolve; });
await page.route("/api/clients/client-1", (route) =>
route.fulfill({ json: MOCK_CLIENTS[0] })
);
await page.route("/api/pets**", async (route) => {
await petsPromise;
await route.fulfill({ json: [] });
});
const navigationPromise = page.goto("/admin/clients/client-1");
// Should show loading state briefly
await expect(page.getByText("Loading client…")).toBeVisible();
// Resolve pets and wait for navigation
resolvePets!();
await navigationPromise;
// After data loads, client name is shown
await expect(page.getByText("Alice Johnson")).toBeVisible();
});
test("direct URL navigation shows error state on failure", async ({ page }) => {
await page.route("/api/clients/nonexistent", (route) =>
route.fulfill({ status: 404, json: { error: "Client not found" } })
);
await page.goto("/admin/clients/nonexistent");
await expect(page.getByText(/client not found/i)).toBeVisible();
await expect(page.getByText("← Back to clients")).toBeVisible();
});
-10
View File
@@ -44,16 +44,6 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
});
}
if (url.includes("/api/invoices/stats/summary")) {
return route.fulfill({
json: {
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
},
});
}
if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } });
}
-2
View File
@@ -2,7 +2,6 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r
import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
import { ServicesPage } from "./pages/Services.js";
import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
@@ -297,7 +296,6 @@ function AdminLayout() {
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/staff" element={<StaffPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
-26
View File
@@ -112,17 +112,9 @@ export function AppointmentsPage() {
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
// null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const weekEnd = addDays(weekStart, 6);
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
const loadAppointments = useCallback(() => {
const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString();
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
</button>
</div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
</div>
</div>
)}
{/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
-236
View File
@@ -1,236 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
export function ClientDetailPage() {
const { clientId } = useParams<{ clientId: string }>();
const [client, setClient] = useState<Client | null>(null);
const [pets, setPets] = useState<Pet[]>([]);
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
const handlePhotoUploaded = useCallback((petId: string) => {
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
}, []);
useEffect(() => {
if (!clientId) {
setError("No client ID provided");
setLoading(false);
return;
}
async function load() {
const id = clientId!;
setLoading(true);
setError(null);
try {
const [clientRes, petsRes] = await Promise.all([
fetch(`/api/clients/${encodeURIComponent(id)}`),
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
]);
if (!clientRes.ok) {
const err = await clientRes.json().catch(() => ({})) as { error?: string };
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
}
if (!petsRes.ok) {
throw new Error(`Pets fetch failed: ${petsRes.status}`);
}
setClient(await clientRes.json() as Client);
setPets(await petsRes.json() as Pet[]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load client");
} finally {
setLoading(false);
}
}
void load();
}, [clientId]);
async function loadVisitLogs(petId: string) {
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
if (r.ok) {
const logs = await r.json() as GroomingVisitLog[];
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
}
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
}
if (loading) {
return (
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
Loading client
</div>
);
}
if (error || !client) {
return (
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<div style={{ marginBottom: "1rem" }}>
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}> Back to clients</Link>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
{error ?? "Client not found"}
</div>
</div>
);
}
return (
<div style={{ fontFamily: "system-ui, sans-serif" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
{client.status === "disabled" && (
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</div>
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
{client.notes && (
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
{client.notes}
</div>
)}
</div>
<Link
to="/admin/clients"
style={{
padding: "0.4rem 0.85rem",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "#fff",
color: "#374151",
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
flexShrink: 0,
}}
>
Back to list
</Link>
</div>
{/* Pets */}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
</div>
{pets.length === 0 ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
{/* Photo + header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
</div>
</div>
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
)}
{/* Visit history */}
{(() => {
const logs = visitLogs[p.id];
const loadingLogs = logsLoading[p.id];
return (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
{!logs && !loadingLogs && (
<button
onClick={() => { void loadVisitLogs(p.id); }}
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
Load history
</button>
)}
</div>
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading</div>}
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
{logs && logs.length > 0 && (
<>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</>
)}
</div>
);
})()}
</div>
))}
</div>
)}
</div>
);
}
+11 -54
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef, useId } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
@@ -647,7 +647,8 @@ export function ClientsPage() {
{/* ── Client modal ── */}
{showClientForm && (
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
<Modal onClose={() => setShowClientForm(false)}>
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
<form onSubmit={submitClient}>
<Field label="Full name">
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
@@ -677,7 +678,8 @@ export function ClientsPage() {
{/* ── Pet modal ── */}
{showPetForm && (
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
<Modal onClose={() => setShowPetForm(false)}>
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
<form onSubmit={submitPet}>
<Field label="Pet name">
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
@@ -751,7 +753,8 @@ export function ClientsPage() {
{/* ── Visit log modal ── */}
{showLogForm && logPetId && (
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
<Modal onClose={() => setShowLogForm(false)}>
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history</p>}
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
<div style={{ marginBottom: "1rem" }}>
@@ -814,7 +817,8 @@ export function ClientsPage() {
{/* ── Delete confirmation modal ── */}
{showDeleteConfirm && selectedClient && (
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
<Modal onClose={() => setShowDeleteConfirm(false)}>
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
<p style={{ fontSize: 14, color: "#374151" }}>
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
</p>
@@ -852,60 +856,13 @@ export function ClientsPage() {
// ─── Shared UI ───────────────────────────────────────────────────────────────
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
const titleId = useId();
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]);
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
>
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
{children}
</div>
</div>
+9 -198
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -173,21 +173,6 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [refundAmount, setRefundAmount] = useState("");
const [refundError, setRefundError] = useState<string | null>(null);
const [refunding, setRefunding] = useState(false);
// Fetch current staff role to determine manager access
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
useEffect(() => {
fetch("/api/staff/me")
.then((r) => r.json())
.then((d) => setStaffMe(d))
.catch(() => setStaffMe(null));
}, []);
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
// Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId
@@ -350,19 +335,6 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/>
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{invoice.stripePaymentIntentId && (
<>
{invoice.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
)}
{invoice.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
)}
{invoice.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" />
)}
</>
)}
</div>
{/* ── Tip Distribution ── */}
@@ -480,92 +452,11 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
{invoice.stripeRefundId && (
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
{showRefundDialog && (
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
Full refund
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="Amount ($)"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }}
/>
</div>
)}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={async () => {
setRefunding(true);
setRefundError(null);
try {
if (refundType === "partial") {
const parsed = parseFloat(refundAmount);
if (isNaN(parsed) || parsed <= 0) {
setRefundError("Please enter a valid amount greater than zero.");
setRefunding(false);
return;
}
}
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowRefundDialog(false);
onUpdated();
} catch (e: unknown) {
setRefundError(e instanceof Error ? e.message : "Refund failed");
} finally {
setRefunding(false);
}
}}
disabled={refunding}
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
>
{refunding ? "Processing…" : "Process Refund"}
</button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div>
</div>
)}
</Modal>
</Modal>
);
}
@@ -606,17 +497,9 @@ export function InvoicesPage() {
const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const LIMIT = 50;
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
async function loadInvoices(newOffset: number) {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (statusFilter) params.set("status", statusFilter);
@@ -695,34 +578,6 @@ export function InvoicesPage() {
</button>
</div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
</div>
{paymentStats.methodBreakdown.length > 0 && (
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
<div style={{ fontSize: 13, color: "#64748b" }}>
{paymentStats.methodBreakdown.map((b) => (
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
))}
</div>
</div>
)}
</div>
)}
{invoiceList.length === 0 ? (
<p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment.
@@ -827,63 +682,19 @@ export function InvoicesPage() {
// ─── Shared UI helpers ────────────────────────────────────────────────────────
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 (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={modalRef}
style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
<div style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}>
{children}
</div>
</div>
+49 -13
View File
@@ -89,14 +89,24 @@ export function SettingsPage() {
fetch("/api/admin/settings")
.then((r) => r.json())
.then(async (data) => {
// The logo is now proxied through the API server so the browser
// never receives an S3 URL — use the proxy path directly as the src.
let logoUrl: string | null = null;
if (data.logoKey) {
try {
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
logoUrl = logoData.url;
}
} catch {
// ignore
}
}
setForm({
businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null,
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
logoUrl,
logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null,
});
@@ -148,21 +158,47 @@ export function SettingsPage() {
}
try {
// Upload directly through the API server to avoid mixed-content issues
// with pre-signed URLs that use the internal HTTP endpoint
const formData = new FormData();
formData.append("file", file);
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
// Step 1: Get presigned upload URL
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
method: "POST",
body: formData,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => null);
throw new Error(err?.error ?? "Failed to upload logo");
throw new Error(err?.error ?? "Failed to get upload URL");
}
const { uploadUrl, key } = await uploadRes.json();
// Step 2: PUT the file directly to S3
const putRes = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!putRes.ok) {
throw new Error("Failed to upload logo to storage");
}
// Step 3: Confirm the upload
const confirmRes = await fetch("/api/admin/settings/logo/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!confirmRes.ok) {
const err = await confirmRes.json().catch(() => null);
throw new Error(err?.error ?? "Failed to confirm logo upload");
}
// Step 4: Fetch the presigned GET URL for display
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null }));
} else {
setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null }));
}
const { logoKey } = await uploadRes.json();
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
setMessage({ type: "success", text: "Logo uploaded." });
refresh();
} catch (err: unknown) {
+2 -2
View File
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)}
{/* Main Content */}
<main className="flex-1 min-h-screen overflow-hidden">
<main className="flex-1 min-h-screen">
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
<div>
<h1 className="text-lg font-semibold text-stone-800">
@@ -340,7 +340,7 @@ export function CustomerPortal() {
</div>
</div>
</div>
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
<div className="p-4 md:p-8 max-w-6xl">
{renderSection()}
</div>
</main>
+22 -1
View File
@@ -123,7 +123,28 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) {
const data = await response.json();
const fetchedAppointments: Appointment[] = data.appointments || data || [];
const rawAppointments: Record<string, unknown>[] = 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 past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
<div className="flex gap-2 flex-wrap overflow-x-auto">
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
@@ -356,48 +356,6 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
const [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const completeModalRef = useRef<HTMLDivElement>(null);
const paymentModalRef = useRef<HTMLDivElement>(null);
// Focus trap + Escape-to-close for both inline modals
useEffect(() => {
const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current;
if (!modalRef) return;
const previouslyFocused = document.activeElement as HTMLElement;
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
const firstFocusable = focusableElements[0];
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab" || !modalRef) return;
const focusables = modalRef.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();
};
}, [isComplete, onClose]);
const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
@@ -462,8 +420,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
if (isComplete) {
return (
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div ref={completeModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@@ -482,8 +440,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
}
return (
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div ref={paymentModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
+18 -1
View File
@@ -116,7 +116,24 @@ export function Dashboard({
const invoicesData = await invoicesRes.json();
const brandingData = await brandingRes.json();
setAppointments(appointmentsData.appointments || []);
const rawAppointments: Record<string, unknown>[] = 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 || []);
// Filter for pending invoices only (not "outstanding")
+4 -3
View File
@@ -27,7 +27,8 @@ interface Appointment {
}
interface AppointmentsResponse {
appointments: Appointment[];
upcoming: Appointment[];
past: Appointment[];
}
interface Props {
@@ -45,7 +46,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -89,7 +90,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
+19 -2
View File
@@ -44,8 +44,25 @@ export function ReportCards({ sessionId }: Props) {
if (response.ok) {
const data = await response.json();
const allAppointments: Appointment[] = data.appointments || data || [];
const reportCardAppointments = allAppointments.filter(
const rawAppointments: Record<string, unknown>[] = data.appointments || data || [];
const transformed: 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,
} as Appointment;
});
const reportCardAppointments = transformed.filter(
(appt) => appt.reportCardId
);
setAppointments(reportCardAppointments);
-7
View File
@@ -119,10 +119,3 @@ uri
database-url
{{- end -}}
{{- end }}
{{/*
Auth secret name always use groombook-auth (sealed secret name)
*/}}
{{- define "groombook.authSecretName" -}}
{{- printf "%s" "groombook-auth" }}
{{- end }}
@@ -50,27 +50,6 @@ spec:
- name: OIDC_AUDIENCE
value: {{ .Values.api.env.oidcAudience | quote }}
{{- end }}
{{- if .Values.api.env.internalBaseUrl }}
- name: OIDC_INTERNAL_BASE
value: {{ .Values.api.env.internalBaseUrl | quote }}
{{- end }}
- name: BETTER_AUTH_URL
value: {{ .Values.api.env.betterAuthUrl | quote }}
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_ID
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_SECRET
- name: BETTER_AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: BETTER_AUTH_SECRET
- name: DATABASE_URL
valueFrom:
secretKeyRef:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: ""
oidcIssuer: ""
oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000"
service:
type: ClusterIP
+45 -54
View File
@@ -200,60 +200,51 @@ export const appointmentGroups = pgTable("appointment_groups", {
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const appointments = pgTable(
"appointments",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "restrict" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "restrict" }),
staffId: uuid("staff_id").references(() => staff.id, {
onDelete: "set null",
}),
// Optional secondary staff (bather/assistant) for tip-split tracking
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
onDelete: "set null",
}),
status: appointmentStatusEnum("status").notNull().default("scheduled"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
notes: text("notes"),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
// Recurring series support
seriesId: uuid("series_id").references(() => recurringSeries.id, {
onDelete: "set null",
}),
seriesIndex: integer("series_index"),
// Multi-pet group booking: links this appointment to others in the same visit
groupId: uuid("group_id").references(() => appointmentGroups.id, {
onDelete: "set null",
}),
// Customer confirmation/cancellation tracking
// Values: "pending" | "confirmed" | "cancelled"
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
customerNotes: text("customer_notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_appointments_client_id").on(t.clientId),
index("idx_appointments_staff_id").on(t.staffId),
index("idx_appointments_start_time").on(t.startTime),
index("idx_appointments_status").on(t.status),
]
);
export const appointments = pgTable("appointments", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "restrict" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "restrict" }),
staffId: uuid("staff_id").references(() => staff.id, {
onDelete: "set null",
}),
// Optional secondary staff (bather/assistant) for tip-split tracking
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
onDelete: "set null",
}),
status: appointmentStatusEnum("status").notNull().default("scheduled"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
notes: text("notes"),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
// Recurring series support
seriesId: uuid("series_id").references(() => recurringSeries.id, {
onDelete: "set null",
}),
seriesIndex: integer("series_index"),
// Multi-pet group booking: links this appointment to others in the same visit
groupId: uuid("group_id").references(() => appointmentGroups.id, {
onDelete: "set null",
}),
// Customer confirmation/cancellation tracking
// Values: "pending" | "confirmed" | "cancelled"
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
customerNotes: text("customer_notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const invoices = pgTable(
"invoices",
+1 -10
View File
@@ -883,7 +883,6 @@ async function seed() {
let appointmentCount = 0;
let invoiceCount = 0;
let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable
const apptBatchSize = 100;
@@ -978,10 +977,6 @@ async function seed() {
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
paidInvoiceCounter++;
const stripePaymentIntentId = invoiceStatus === "paid"
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
: null;
invoiceBatch.push({
id: invoiceId,
@@ -994,7 +989,6 @@ async function seed() {
status: invoiceStatus,
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
paidAt,
stripePaymentIntentId,
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
});
@@ -1098,16 +1092,13 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
paidInvoiceCounter++;
invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt,
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
notes: null,
paidAt, notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,
-6
View File
@@ -152,16 +152,10 @@ export interface Invoice {
status: InvoiceStatus;
paymentMethod: PaymentMethod | null;
paidAt: string | null;
stripePaymentIntentId: string | null;
stripeRefundId: string | null;
paymentFailureReason: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
lineItems?: InvoiceLineItem[];
// Transient fields populated from Stripe API (not stored in DB)
cardLast4?: string | null;
paymentStatus?: string | null;
tipSplits?: InvoiceTipSplit[];
}