fix(gro66): E2E selector fix + groomer isolation + portal confirm/cancel

* Implement confirm/cancel in customer portal (GRO-50)

Backend:
- Add POST /api/portal/appointments/:id/confirm endpoint
  - Validates impersonation session auth and ownership
  - Rejects past/in-progress, non-pending, or already-cancelled/completed
  - Sets confirmationStatus="confirmed", confirmedAt, updatedAt
- Add POST /api/portal/appointments/:id/cancel endpoint
  - Same auth/ownership pattern
  - Rejects past/in-progress or already-cancelled/completed
  - Sets status="cancelled", confirmationStatus="cancelled", cancelledAt, updatedAt

Frontend (Appointments.tsx):
- Add confirmationStatus field to Appointment type and mock data
- Add ConfirmationSection component: shows status badge + confirm button
- Add CancelAppointmentButton: wires to cancel API with loading/error state
- Wire existing Cancel button to CancelAppointmentButton
- Show confirmation status badge in expanded view for upcoming appointments

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(gro-48): row-level data scoping for groomer role (RBAC Phase 2)

Filter query results at the route handler level when staff role is groomer:

- GET /api/appointments: WHERE staffId = groomer OR batherStaffId = groomer
- GET /api/appointments/🆔 403 if not assigned to groomer (as staff or bather)
- GET /api/clients: Clients with ≥1 appointment for this groomer (via exists subquery)
- GET /api/clients/🆔 403 if no appointment linkage
- GET /api/pets: Pets owned by groomer-linked clients (via exists subquery)
- GET /api/pets/:petId: 403 if no appointment linkage

Managers and receptionists: no change.

Added exists to @groombook/db exports (was missing from re-export).
Added groomerIsolation unit tests for role guard and filter logic.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-50): add portal confirm/cancel tests and fix ConfirmationSection state

- Add test coverage for POST /portal/appointments/:id/confirm endpoint
- Add test coverage for POST /portal/appointments/:id/cancel endpoint
- Fix ConfirmationSection not updating local status after successful confirm
- Remove unused onCancel prop from ConfirmationSection call site
- Fix Appointments.test.tsx missing confirmationStatus field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(gro-50): add ConfirmationSection UI component tests

Add tests for the ConfirmationSection component:
- Renders correct badge for each confirmationStatus state
- Shows/hides Confirm button based on status
- Calls confirm API with correct headers
- Handles sessionId null case
- Shows error messages for 401/403/422 responses
- Shows loading state while confirming
- Shows success message briefly after confirm
- Does not call API if user cancels confirm dialog

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-48): address QA review feedback — staffRow?.role and portal TS guards

- appointments.ts: use staffRow?.role (consistent with clients.ts/pets.ts)
  to handle undefined staff context safely
- portal.ts: add null guards on .returning() results for confirm and cancel
  endpoints (TS18048: 'updated' is possibly undefined)
- All 188 tests passing; TypeScript typecheck clean

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro66): use specific selector for banner visibility assertion

Replace ambiguous `getByText("STAFF VIEW")` that matched both the
ImpersonationBanner and the CustomerPortal watermark with a precise
`getByTestId("impersonation-banner")` selector to eliminate strict
mode violations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-66): add missing afterEach to vitest import

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-48): add icalToken to MANAGER mock after rebase

After rebasing onto origin/main (which added icalToken to the staff
schema via GRO-107), the MANAGER mock in groomerIsolation.test.ts was
missing the new required field. Added icalToken: null to the MANAGER
constant. factories.ts is clean (no duplicate icalToken after rebase).

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-47): add non-null assertions on Drizzle RETURNING results

Drizzle's update().returning() types the array element as T | undefined.
After the if (!appt) guard, updated is still typed as possibly undefined
because RETURNING can succeed with no rows. Add ! assertions since
we already guard with the existence check.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Flea Flicker <fleaflicker@groombook.ai>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing>
This commit was merged in pull request #128.
This commit is contained in:
groombook-engineer[bot]
2026-03-27 14:23:19 +00:00
committed by GitHub
parent 8ab6319311
commit 9eb0c3d151
11 changed files with 869 additions and 36 deletions
+190 -2
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Appointment } from "../portal/mockData.js";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection } from "../portal/sections/Appointments.js";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.js";
const UPCOMING_APPT: Appointment = {
id: "appt-1",
@@ -18,6 +18,7 @@ const UPCOMING_APPT: Appointment = {
status: "confirmed",
notes: "",
customerNotes: "",
confirmationStatus: "pending",
};
const PAST_APPT: Appointment = {
@@ -191,4 +192,191 @@ describe("CustomerNotesSection", () => {
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "cancelled" }} sessionId="test-session-id" />);
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
});
});
describe("ConfirmationSection", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
vi.stubGlobal("confirm", vi.fn(() => true));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders pending badge when confirmationStatus is pending", () => {
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
expect(screen.getByText("Pending confirmation")).toBeInTheDocument();
});
it("renders confirmed badge when confirmationStatus is confirmed", () => {
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
});
it("renders cancelled badge when confirmationStatus is cancelled", () => {
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
expect(screen.getByText("Cancelled")).toBeInTheDocument();
});
it("shows Confirm Appointment button when status is pending", () => {
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
expect(screen.getByRole("button", { name: /Confirm Appointment/i })).toBeInTheDocument();
});
it("does not show Confirm button when already confirmed", () => {
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
});
it("does not show Confirm button when cancelled", () => {
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
});
it("calls confirm API and updates local status on success", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
} as Response);
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({ method: "POST" })
);
});
await waitFor(() => {
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
});
});
it("sends X-Impersonation-Session-Id header when session exists", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
} as Response);
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({
headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id",
}),
})
);
});
});
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
} as Response);
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId={null} />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({
headers: expect.not.objectContaining({
"X-Impersonation-Session-Id": expect.anything(),
}),
})
);
});
});
it("shows error message when confirm API returns 401", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: "Unauthorized" }),
} as Response);
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
await waitFor(() => {
expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
});
});
it("shows error message when confirm API returns 403", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 403,
json: async () => ({ error: "Forbidden" }),
} as Response);
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
await waitFor(() => {
expect(screen.getByText(/Forbidden/i)).toBeInTheDocument();
});
});
it("shows error message when confirm API returns 422 (invalid state)", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 422,
json: async () => ({ error: "Cannot confirm - appointment is not in pending state" }),
} as Response);
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
await waitFor(() => {
expect(screen.getByText(/Cannot confirm/i)).toBeInTheDocument();
});
});
it("does not call confirm API if user cancels the confirmation dialog", async () => {
vi.stubGlobal("confirm", vi.fn(() => false));
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
expect(global.fetch).not.toHaveBeenCalled();
});
it("shows loading state while confirming", async () => {
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
// Get button reference before clicking
const btn = screen.getByRole("button", { name: /Confirm Appointment/i });
fireEvent.click(btn);
await waitFor(() => {
expect(screen.getByText(/Confirming.../i)).toBeInTheDocument();
});
// Button is disabled while loading
expect(btn).toBeDisabled();
});
it("shows success message briefly after confirm", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
} as Response);
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
await waitFor(() => {
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
});
});
});
+12 -11
View File
@@ -41,6 +41,7 @@ export interface Appointment {
duration: number;
price: number;
status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled";
confirmationStatus: "pending" | "confirmed" | "cancelled";
notes: string;
customerNotes: string;
reportCardId?: string;
@@ -177,21 +178,21 @@ export const UPCOMING_APPOINTMENTS: Appointment[] = [
id: "a1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Full Groom"], addOns: ["De-shedding Treatment"],
date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145,
status: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed",
status: "confirmed", confirmationStatus: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed",
customerNotes: "",
},
{
id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
services: ["Full Groom"], addOns: ["Teeth Brushing"],
date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90,
status: "confirmed", notes: "First visit with Morgan — patient with anxious pets",
status: "confirmed", confirmationStatus: "confirmed", notes: "First visit with Morgan — patient with anxious pets",
customerNotes: "",
},
{
id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Bath & Brush"], addOns: [],
date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55,
status: "pending", notes: "",
status: "pending", confirmationStatus: "pending", notes: "",
customerNotes: "",
},
];
@@ -201,56 +202,56 @@ export const PAST_APPOINTMENTS: Appointment[] = [
id: "pa1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"],
date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160,
status: "completed", notes: "", reportCardId: "rc1",
status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc1",
customerNotes: "",
},
{
id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
services: ["Full Groom"], addOns: ["Teeth Brushing"],
date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88,
status: "completed", notes: "", reportCardId: "rc2",
status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc2",
customerNotes: "",
},
{
id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Bath & Brush"], addOns: [],
date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55,
status: "completed", notes: "",
status: "completed", confirmationStatus: "confirmed", notes: "",
customerNotes: "",
},
{
id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
services: ["Puppy's First Groom"], addOns: [],
date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62,
status: "completed", notes: "",
status: "completed", confirmationStatus: "confirmed", notes: "",
customerNotes: "",
},
{
id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Full Groom"], addOns: ["Nail Grinding"],
date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132,
status: "completed", notes: "Holiday groom",
status: "completed", confirmationStatus: "confirmed", notes: "Holiday groom",
customerNotes: "",
},
{
id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex",
services: ["Full Groom"], addOns: [],
date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110,
status: "completed", notes: "",
status: "completed", confirmationStatus: "confirmed", notes: "",
customerNotes: "",
},
{
id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
services: ["Bath & Brush"], addOns: [],
date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48,
status: "completed", notes: "",
status: "completed", confirmationStatus: "confirmed", notes: "",
customerNotes: "",
},
{
id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
services: ["Bath & Brush"], addOns: ["De-shedding Treatment"],
date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85,
status: "completed", notes: "",
status: "completed", confirmationStatus: "confirmed", notes: "",
customerNotes: "",
},
];
+120 -3
View File
@@ -41,6 +41,12 @@ const STATUS_COLORS: Record<string, string> = {
cancelled: "bg-red-100 text-red-600",
};
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
confirmed: "bg-green-100 text-green-700",
pending: "bg-amber-100 text-amber-700",
cancelled: "bg-red-100 text-red-600",
};
export function AppointmentsSection({ readOnly, sessionId }: Props) {
const [showBooking, setShowBooking] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -165,14 +171,15 @@ function AppointmentCard({
{isUpcoming(appt) && !readOnly && (
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
)}
{isUpcoming(appt) && (
<ConfirmationSection appointment={appt} sessionId={sessionId} />
)}
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
<div className="flex gap-2 mt-3">
<button className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Reschedule
</button>
<button className="text-xs px-3 py-1.5 border border-red-200 rounded-lg text-red-600 hover:bg-red-50">
Cancel
</button>
<CancelAppointmentButton appointment={appt} sessionId={sessionId} />
</div>
)}
{appt.reportCardId && (
@@ -188,6 +195,116 @@ function AppointmentCard({
);
}
export function ConfirmationSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) {
const [confirming, setConfirming] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
const [confirmSuccess, setConfirmSuccess] = useState(false);
// Local state mirrors confirmationStatus so the badge updates immediately after confirm
const [localStatus, setLocalStatus] = useState(appt.confirmationStatus);
async function handleConfirm() {
if (!window.confirm("Confirm this appointment?")) return;
setConfirming(true);
setConfirmError(null);
try {
const headers: Record<string, string> = {};
if (sessionId) {
headers["X-Impersonation-Session-Id"] = sessionId;
}
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
method: "POST",
headers,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed to confirm" }));
throw new Error(err.error || `HTTP ${res.status}`);
}
setLocalStatus("confirmed");
setConfirmSuccess(true);
setTimeout(() => setConfirmSuccess(false), 2000);
} catch (e) {
setConfirmError(e instanceof Error ? e.message : "Failed to confirm");
} finally {
setConfirming(false);
}
}
const currentStatus = localStatus ?? appt.confirmationStatus;
const statusLabel = currentStatus === "confirmed"
? "✓ Confirmed"
: currentStatus === "pending"
? "Pending confirmation"
: "Cancelled";
return (
<div className="mt-3 p-3 bg-stone-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${CONFIRMATION_STATUS_COLORS[currentStatus] || ""}`}>
{statusLabel}
</span>
</div>
{!confirmSuccess && currentStatus === "pending" && (
<button
onClick={handleConfirm}
disabled={confirming}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{confirming && <Loader2 size={12} className="animate-spin" />}
{confirming ? "Confirming..." : "Confirm Appointment"}
</button>
)}
{confirmSuccess && (
<span className="text-xs text-green-600 font-medium">Confirmed!</span>
)}
</div>
{confirmError && <p className="text-xs text-red-500 mt-1">{confirmError}</p>}
</div>
);
}
function CancelAppointmentButton({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) {
const [cancelling, setCancelling] = useState(false);
const [cancelError, setCancelError] = useState<string | null>(null);
async function handleCancel() {
if (!window.confirm("Cancel this appointment? This cannot be undone.")) return;
setCancelling(true);
setCancelError(null);
try {
const headers: Record<string, string> = {};
if (sessionId) {
headers["X-Impersonation-Session-Id"] = sessionId;
}
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
method: "POST",
headers,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed to cancel" }));
throw new Error(err.error || `HTTP ${res.status}`);
}
window.location.reload();
} catch (e) {
setCancelError(e instanceof Error ? e.message : "Failed to cancel");
setCancelling(false);
}
}
return (
<>
<button
onClick={handleCancel}
disabled={cancelling}
className="text-xs px-3 py-1.5 border border-red-200 rounded-lg text-red-600 hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelling ? "Cancelling..." : "Cancel"}
</button>
{cancelError && <p className="text-xs text-red-500 mt-1">{cancelError}</p>}
</>
);
}
export function CustomerNotesSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) {
const [notes, setNotes] = useState(appt.customerNotes || "");
const [saving, setSaving] = useState(false);