Updates 1
CI / ci (push) Canceled after 0s

The session built:

Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.

It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
This commit is contained in:
Stefan Lazic committed 2026-10-17 22:29:00 +01:00
1 parent 9f981173f4
commit 17eee5b932
95 files changed
+7955 -150

No files matched your search

+59 -51
View File
@@ -4,7 +4,6 @@ import Link from "next/link";
import { auth } from "@/auth";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
@@ -29,12 +28,26 @@ const STATUS_MAP: Record<string, AppointmentStatus> = {
NO_SHOW: "no_show",
};
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
const CANCELLED_STATUSES = ["CANCELLED", "NO_SHOW"] as const;
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
const REBOOKABLE_STATUSES = new Set(["COMPLETED", "CANCELLED", "NO_SHOW"]);
export default async function AppointmentsPage() {
const PAGE_SIZE = 20;
const MAX_LIMIT = 500;
function parseLimit(raw: string | undefined): number {
const n = Number.parseInt(raw ?? "", 10);
if (!Number.isFinite(n) || n < PAGE_SIZE) return PAGE_SIZE;
return Math.min(n, MAX_LIMIT);
}
export default async function AppointmentsPage({
searchParams,
}: {
searchParams: Promise<{ limit?: string }>;
}) {
const { limit: rawLimit } = await searchParams;
const limit = parseLimit(rawLimit);
const session = await auth();
if (!session?.user) {
@@ -64,7 +77,9 @@ export default async function AppointmentsPage() {
const customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id },
});
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
const bell = (
<InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />
);
if (!customerProfile) {
return (
@@ -86,37 +101,28 @@ export default async function AppointmentsPage() {
);
}
const [upcoming, past, cancelled] = await Promise.all([
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...UPCOMING_STATUSES] }),
listAppointments(prisma, { customerId: customerProfile.id, statuses: ["COMPLETED"] }),
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...CANCELLED_STATUSES] }),
]);
// One list, newest first. Fetch a spare row so we know whether "Show older" is needed.
const fetched = await listAppointments(prisma, {
customerId: customerProfile.id,
order: "desc",
take: limit + 1,
});
const hasMore = fetched.length > limit;
const appointments = hasMore ? fetched.slice(0, limit) : fetched;
const renderList = (appointments: typeof upcoming, kind: "upcoming" | "past" | "cancelled") => {
if (appointments.length === 0) {
return (
<EmptyState
icon={CalendarPlus}
title={
kind === "upcoming"
? "No upcoming appointments"
: kind === "past"
? "No past appointments"
: "No cancelled appointments"
}
description={kind === "upcoming" ? "Book your next visit in a few taps." : undefined}
action={
kind === "upcoming" ? (
<Button asChild>
<Link href="/book">Book Now</Link>
</Button>
) : undefined
}
/>
);
}
return (
const list =
appointments.length === 0 ? (
<EmptyState
icon={CalendarPlus}
title="No appointments yet"
description="Book your next visit in a few taps."
action={
<Button asChild>
<Link href="/book">Book Now</Link>
</Button>
}
/>
) : (
<div className="space-y-4">
{appointments.map((appointment) => (
<AppointmentCard
@@ -128,6 +134,7 @@ export default async function AppointmentsPage() {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
timeZone: business.timezone,
})}
formattedTime={appointment.startAt.toLocaleTimeString("en-GB", {
@@ -143,7 +150,7 @@ export default async function AppointmentsPage() {
>
View
</Link>
{kind === "upcoming" && RESCHEDULABLE_STATUSES.has(appointment.status) && (
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
<Link
href={`/appointments/${appointment.id}/reschedule`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
@@ -151,33 +158,34 @@ export default async function AppointmentsPage() {
Reschedule
</Link>
)}
{kind === "upcoming" && CANCELLABLE_STATUSES.has(appointment.status) && (
{CANCELLABLE_STATUSES.has(appointment.status) && (
<CancelAppointmentDialog appointmentId={appointment.id} />
)}
{kind !== "upcoming" && <BookAgainButton appointmentId={appointment.id} />}
{REBOOKABLE_STATUSES.has(appointment.status) && (
<BookAgainButton appointmentId={appointment.id} />
)}
</div>
}
/>
))}
{hasMore && (
<div className="flex justify-center pt-2">
<Link
href={`/appointments?limit=${limit + PAGE_SIZE}`}
scroll={false}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
Show older
</Link>
</div>
)}
</div>
);
};
return (
<div className="pb-12">
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
<div className="mx-auto max-w-2xl px-6 pt-6">
<Tabs defaultValue="upcoming">
<TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
<TabsTrigger value="past">Past</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
</Tabs>
</div>
<div className="mx-auto max-w-2xl px-6 pt-6">{list}</div>
</div>
);
}
@@ -35,14 +35,14 @@ export default async function OwnerStaffTimeOffPage({ params }: { params: Promis
if (!target) notFound();
const { upcoming, past } = await listTimeOff(prisma, id);
// Whole-day rows only — partial-day "Block Time" entries are managed from the owner calendar (see owner-block-time-button.tsx).
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
const toEditable = (rows: typeof upcoming) =>
wholeDay(rows).map((t) => ({
rows.map((t) => ({
id: t.id,
startDate: toCalendarDate(t.startDate),
endDate: toCalendarDate(t.endDate),
reason: t.reason,
startMinutes: t.startMinutes,
endMinutes: t.endMinutes,
}));
return (
+22 -1
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { CalendarX2, Mail, Phone } from "lucide-react";
import { CalendarX2, Download, Mail, Phone } from "lucide-react";
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
import { notFound, redirect } from "next/navigation";
import { AppointmentCard } from "@/components/ui/appointment-card";
@@ -12,6 +12,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { clientDirectoryWhere } from "@/services/customer/client-directory";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
@@ -60,6 +62,9 @@ export default async function StaffCustomerDetailsPage({
(await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0;
if (!inDirectory) notFound();
// Only offered to those who may use it; the export route re-checks the same permission itself.
const canExport = await hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE);
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
listAppointments(prisma, {
customerId: id,
@@ -271,6 +276,22 @@ export default async function StaffCustomerDetailsPage({
</p>
</div>
</div>
{canExport && (
<div className="border-line border-t pt-4">
{/* A plain link, not next/link: the response is a file download, not a page. */}
<a
href={`/api/customers/${customer.id}/export`}
download
className="border-line flex h-12 w-full items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
>
<Download className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Export client data (CSV)
</a>
<p className="text-muted mt-2 text-xs">
Profile, appointments, loyalty and messages sent. Private staff notes are not included.
</p>
</div>
)}
</Card>
<div className="mt-4 space-y-4 lg:mt-0">
@@ -18,15 +18,14 @@ export default async function StaffTimeOffPage() {
if (!staff) redirect("/home");
const { upcoming, past } = await listTimeOff(prisma, staff.id);
// Whole-day rows only — partial-day "Block Time" entries are managed from the calendar (see block-time-button.tsx).
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
const toEditable = (rows: typeof upcoming) =>
wholeDay(rows).map((t) => ({
rows.map((t) => ({
id: t.id,
startDate: toCalendarDate(t.startDate),
endDate: toCalendarDate(t.endDate),
reason: t.reason,
startMinutes: t.startMinutes,
endMinutes: t.endMinutes,
}));
return (
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { prisma } from "@/lib/db/prisma";
import { exportCustomerData } from "@/services/customer/customer-export.service";
/**
* Download one customer's data as CSV. A Route Handler (not a Server Action)
* because the browser needs real Content-Type / Content-Disposition headers,
* same reason as the appointment .ics download. All authorisation —
* MANAGE_BUSINESS_PROFILE, business scoping, the audit entry — lives in
* exportCustomerData; the client detail page that links here is open to every
* staff member, so nothing may rely on having reached this URL from it.
*/
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
const context = await getActingBusinessContext();
if (!context) return NextResponse.json({ error: "You don't have permission to do that." }, { status: 403 });
const { id } = await params;
const result = await exportCustomerData(prisma, {
actorUserId: session.user.id,
businessId: context.businessId,
customerId: id,
});
if (!result.ok) {
return result.reason === "NOT_FOUND"
? NextResponse.json({ error: "Customer not found." }, { status: 404 })
: NextResponse.json({ error: "You don't have permission to do that." }, { status: 403 });
}
return new NextResponse(result.csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${result.filename}"`,
// Personal data: never cache, in the browser or any proxy.
"Cache-Control": "private, no-store",
},
});
}
@@ -53,12 +53,14 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
// Deliberately not revalidating here (unlike reschedule) — every affected
// Deliberately not revalidating here (unlike reschedule). Every affected
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
// extra freshness, and doing it immediately would drop the cancelled
// appointment out of the Upcoming list mid-render, unmounting this very
// dialog before the customer can read the late-cancellation notice or
// click "Done". The dialog's own onClose already calls router.refresh().
// extra freshness, and doing it immediately would re-render the card as
// Cancelled — its actions are derived from status, so the Cancel button
// (and the dialog it owns) is swapped for Book Again and unmounted before
// the customer can read the late-cancellation notice or click "Done". The
// dialog's own onClose already calls router.refresh(), and the card then
// stays in the list with a Cancelled pill.
return { success: true, wasLateCancellation: result.wasLateCancellation };
}
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff } from "@/services/staff/staff.service";
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
@@ -91,4 +93,93 @@ describe("Owner-managed breaks and time off (integration)", () => {
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
expect(staffBBreaks).toHaveLength(0);
});
describe("partial-day Block Time entries (edit and delete)", () => {
async function createBlock(staffId: string) {
const created = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: "2027-02-01", endDate: "2027-02-01", reason: "Team meeting", startMinutes: 14 * 60, endMinutes: 15 * 60 },
});
expect(created.success).toBe(true);
if (!created.success) throw new Error("setup failed");
return created.timeOff;
}
it("edits the date, times and reason of a block, keeping it partial-day", async () => {
const { staffId } = await createTestStaff(business);
const block = await createBlock(staffId);
const updated = await updateTimeOff(prisma, {
timeOffId: block.id,
staffId,
businessTimezone: business.timezone,
input: { startDate: "2027-02-02", endDate: "2027-02-02", reason: "Dentist", startMinutes: 16 * 60, endMinutes: 17 * 60 + 30 },
});
expect(updated.success).toBe(true);
const stored = await prisma.timeOff.findUnique({ where: { id: block.id } });
expect(stored?.startDate.toISOString().slice(0, 10)).toBe("2027-02-02");
expect(stored?.startMinutes).toBe(16 * 60);
expect(stored?.endMinutes).toBe(17 * 60 + 30);
expect(stored?.reason).toBe("Dentist");
});
it("rejects an invalid edited range and non-integer minutes without touching the row", async () => {
const { staffId } = await createTestStaff(business);
const block = await createBlock(staffId);
for (const bad of [
{ startMinutes: 15 * 60, endMinutes: 14 * 60 },
{ startMinutes: 14 * 60 + 0.5, endMinutes: 15 * 60 },
]) {
const result = await updateTimeOff(prisma, {
timeOffId: block.id,
staffId,
businessTimezone: business.timezone,
input: { startDate: "2027-02-01", endDate: "2027-02-01", ...bad },
});
expect(result.success).toBe(false);
}
const stored = await prisma.timeOff.findUnique({ where: { id: block.id } });
expect(stored?.startMinutes).toBe(14 * 60);
expect(stored?.endMinutes).toBe(15 * 60);
});
it("reports a conflicting appointment when an edited block overlaps it, and saves once acknowledged", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Mon 1 Feb 2027, 10:00–10:30 London (GMT).
await createTestAppointment(business, { customerId, staffId, startAt: new Date("2027-02-01T10:00:00Z") });
const block = await createBlock(staffId);
const input = { startDate: "2027-02-01", endDate: "2027-02-01", startMinutes: 10 * 60, endMinutes: 11 * 60 };
const blocked = await updateTimeOff(prisma, { timeOffId: block.id, staffId, businessTimezone: business.timezone, input });
expect(blocked.success).toBe(false);
expect(blocked.conflicts).toHaveLength(1);
expect((await prisma.timeOff.findUnique({ where: { id: block.id } }))?.startMinutes).toBe(14 * 60);
const forced = await updateTimeOff(prisma, {
timeOffId: block.id,
staffId,
businessTimezone: business.timezone,
input,
acknowledgeConflicts: true,
});
expect(forced.success).toBe(true);
expect((await prisma.timeOff.findUnique({ where: { id: block.id } }))?.startMinutes).toBe(10 * 60);
});
it("deletes a block, but never another staff member's block", async () => {
const { staffId } = await createTestStaff(business);
const { staffId: otherStaffId } = await createTestStaff(business);
const block = await createBlock(staffId);
expect((await deleteTimeOff(prisma, { timeOffId: block.id, staffId: otherStaffId })).success).toBe(false);
expect(await prisma.timeOff.findUnique({ where: { id: block.id } })).not.toBeNull();
expect((await deleteTimeOff(prisma, { timeOffId: block.id, staffId })).success).toBe(true);
expect(await prisma.timeOff.findUnique({ where: { id: block.id } })).toBeNull();
});
});
});
+5 -2
View File
@@ -9,6 +9,9 @@ import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/st
import type { TimeOffInput } from "@/services/staff/staff.service";
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
/** Whole-day time off omits the minutes; an edited partial-day "Block Time" entry carries both. */
type TimeOffActionInput = Pick<TimeOffInput, "startDate" | "endDate" | "reason" | "startMinutes" | "endMinutes">;
export interface TimeOffConflictItem {
id: string;
customerName: string;
@@ -57,7 +60,7 @@ async function requireStaffManagerFor(staffId: string) {
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
export async function ownerCreateTimeOffAction(
staffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
@@ -91,7 +94,7 @@ export async function ownerCreateTimeOffAction(
export async function ownerUpdateTimeOffAction(
staffId: string,
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
+6 -3
View File
@@ -5,6 +5,9 @@ import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
import type { TimeOffInput } from "@/services/staff/staff.service";
/** Whole-day time off omits the minutes; an edited partial-day "Block Time" entry carries both. */
type TimeOffActionInput = Pick<TimeOffInput, "startDate" | "endDate" | "reason" | "startMinutes" | "endMinutes">;
export interface TimeOffConflictItem {
id: string;
customerName: string;
@@ -27,9 +30,9 @@ function mapConflicts(conflicts: { id: string; customer: { firstName: string; la
}));
}
/** Whole-day time off only — the settings editor doesn't handle partial-day blocks; that's the calendar's "Block Time" action (see block-time.ts). */
/** Creates whole-day time off. Partial-day "Block Time" entries are created from the calendar (see block-time.ts) but listed, edited and removed with the same update/delete actions below. */
export async function createTimeOffAction(
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const staff = await getActingStaff();
@@ -49,7 +52,7 @@ export async function createTimeOffAction(
export async function updateTimeOffAction(
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const staff = await getActingStaff();
@@ -15,17 +15,38 @@ export interface EditableTimeOff {
startDate: string;
endDate: string;
reason: string | null;
/** Both set = a partial-day "Block Time" entry (single day, minutes from midnight); both null/omitted = whole-day time off. */
startMinutes?: number | null;
endMinutes?: number | null;
}
export type TimeOffSheetInput = Pick<TimeOffInput, "startDate" | "endDate" | "reason" | "startMinutes" | "endMinutes">;
export function isPartialDay(t: Pick<EditableTimeOff, "startMinutes" | "endMinutes">): boolean {
return t.startMinutes != null && t.endMinutes != null;
}
export function minutesToHhmm(minutes: number): string {
minutes = Math.min(minutes, 24 * 60 - 1); // a block ending at midnight (1440) shows as 23:59 in a time input
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function hhmmToMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
}
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface TimeOffActionSet {
create: (
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
input: TimeOffSheetInput,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
update: (
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
input: TimeOffSheetInput,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
@@ -58,6 +79,9 @@ export function TimeOffFormSheet({
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
const [reason, setReason] = useState(existing?.reason ?? "");
const partial = existing ? isPartialDay(existing) : false;
const [startTime, setStartTime] = useState(partial ? minutesToHhmm(existing!.startMinutes!) : "12:00");
const [endTime, setEndTime] = useState(partial ? minutesToHhmm(existing!.endMinutes!) : "13:00");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<TimeOffConflictItem[] | null>(null);
const [isPending, startTransition] = useTransition();
@@ -67,6 +91,8 @@ export function TimeOffFormSheet({
setStartDate(existing?.startDate ?? todayIso());
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
setReason(existing?.reason ?? "");
setStartTime(partial ? minutesToHhmm(existing!.startMinutes!) : "12:00");
setEndTime(partial ? minutesToHhmm(existing!.endMinutes!) : "13:00");
setError(null);
setConflicts(null);
};
@@ -77,13 +103,27 @@ export function TimeOffFormSheet({
};
const submit = (acknowledgeConflicts: boolean) => {
if (startDate > endDate) {
let minutes: Pick<TimeOffSheetInput, "startMinutes" | "endMinutes"> = {};
if (partial) {
const startMinutes = hhmmToMinutes(startTime);
const endMinutes = hhmmToMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
minutes = { startMinutes, endMinutes };
} else if (startDate > endDate) {
setError("Start date must be on or before the end date.");
return;
}
setError(null);
startTransition(async () => {
const input = { startDate, endDate, reason: reason.trim() || undefined };
const input = {
startDate,
endDate: partial ? startDate : endDate,
reason: reason.trim() || undefined,
...minutes,
};
const result: TimeOffActionResult = existing
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
@@ -102,7 +142,7 @@ export function TimeOffFormSheet({
startDeleteTransition(async () => {
const result = await actions.remove(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't remove this time off.");
setError(result.error ?? (partial ? "Couldn't remove this block." : "Couldn't remove this time off."));
return;
}
handleClose();
@@ -111,7 +151,7 @@ export function TimeOffFormSheet({
};
return (
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Time Off" : "New Time Off"}>
<BottomSheet open={open} onClose={handleClose} title={partial ? "Edit Blocked Time" : existing ? "Edit Time Off" : "New Time Off"}>
<div className="space-y-4 pb-2">
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
@@ -146,13 +186,23 @@ export function TimeOffFormSheet({
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
{partial ? (
<>
<Input label="Date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
</>
) : (
<div className="grid grid-cols-2 gap-4">
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
)}
<Input
label="Reason (optional)"
placeholder="e.g. Vacation"
placeholder={partial ? "e.g. Team meeting" : "e.g. Vacation"}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
@@ -166,7 +216,7 @@ export function TimeOffFormSheet({
</Button>
{existing && (
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
Remove Time Off
{partial ? "Remove Block" : "Remove Time Off"}
</Button>
)}
</>
@@ -4,7 +4,7 @@ import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { EditableTimeOff, TimeOffActionSet } from "./time-off-form-sheet";
import { TimeOffFormSheet } from "./time-off-form-sheet";
import { TimeOffFormSheet, isPartialDay, minutesToHhmm } from "./time-off-form-sheet";
function formatRange(startDate: string, endDate: string): string {
const start = new Date(`${startDate}T00:00:00`);
@@ -15,6 +15,11 @@ function formatRange(startDate: string, endDate: string): string {
return `${startLabel} – ${endLabel}`;
}
function formatEntry(t: EditableTimeOff): string {
const range = formatRange(t.startDate, t.endDate);
return isPartialDay(t) ? `${range}, ${minutesToHhmm(t.startMinutes!)}–${minutesToHhmm(t.endMinutes!)}` : range;
}
export function TimeOffList({
upcoming,
past,
@@ -61,7 +66,7 @@ export function TimeOffList({
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
>
<div>
<p className="text-sm font-bold">{formatRange(t.startDate, t.endDate)}</p>
<p className="text-sm font-bold">{formatEntry(t)}</p>
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
</div>
</button>
@@ -77,7 +82,7 @@ export function TimeOffList({
<ul className="divide-y divide-line rounded-md border border-line bg-stone/50">
{past.map((t) => (
<li key={t.id} className="px-5 py-4">
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
<p className="text-sm font-bold text-muted">{formatEntry(t)}</p>
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
</li>
))}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { parseCsv } from "../parse-csv";
import { escapeCsvCell, toCsv } from "../write-csv";
describe("escapeCsvCell", () => {
it("leaves plain text alone and renders empty values as blank", () => {
expect(escapeCsvCell("Jamie Fraser")).toBe("Jamie Fraser");
expect(escapeCsvCell(null)).toBe("");
expect(escapeCsvCell(undefined)).toBe("");
expect(escapeCsvCell("")).toBe("");
});
it("renders numbers, booleans and dates as text", () => {
expect(escapeCsvCell(12.5)).toBe("12.5");
expect(escapeCsvCell(false)).toBe("false");
expect(escapeCsvCell(new Date("2026-10-25T01:30:00Z"))).toBe("2026-10-25T01:30:00.000Z");
});
it("quotes cells containing commas, quotes and line breaks, doubling inner quotes", () => {
expect(escapeCsvCell("Fraser, Jamie")).toBe('"Fraser, Jamie"');
expect(escapeCsvCell('Said "hello"')).toBe('"Said ""hello"""');
expect(escapeCsvCell("line one\nline two")).toBe('"line one\nline two"');
expect(escapeCsvCell("a\r\nb")).toBe('"a\r\nb"');
});
it.each(["=SUM(A1:A9)", "+44 7700 900123", "-2+3", "@cmd", "\tindented", "\rcarriage"])(
"prefixes %j so a spreadsheet cannot run it as a formula",
(value) => {
expect(escapeCsvCell(value).startsWith("'") || escapeCsvCell(value).startsWith(`"'`)).toBe(true);
},
);
it("prefixes before quoting, so a formula containing a comma is still neutralised", () => {
expect(escapeCsvCell('=HYPERLINK("http://evil.example","x")')).toBe(`"'=HYPERLINK(""http://evil.example"",""x"")"`);
});
it("does not touch a formula character that is not the first character", () => {
expect(escapeCsvCell("a=b")).toBe("a=b");
expect(escapeCsvCell("Mon - Fri")).toBe("Mon - Fri");
expect(escapeCsvCell("o'brien")).toBe("o'brien");
});
});
describe("toCsv", () => {
it("starts with a byte-order mark, separates records with CRLF and ends with a newline", () => {
expect(toCsv([["a", "b"], ["1", "2"]])).toBe("a,b\r\n1,2\r\n");
});
it("round-trips through parseCsv, apart from the formula-guard apostrophe", () => {
const rows = [
["section", "value"],
["profile", 'Fraser, "Jamie"'],
["appointment", "line one\nline two"],
["appointment", "plain"],
["profile", ""],
["profile", "=1+1"],
];
expect(parseCsv(toCsv(rows))).toEqual([
["section", "value"],
["profile", 'Fraser, "Jamie"'],
["appointment", "line one\nline two"],
["appointment", "plain"],
["profile", ""],
["profile", "'=1+1"],
]);
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* The writing counterpart of parse-csv.ts, used by the customer data export.
* Two jobs beyond joining cells with commas:
*
* - Quoting (RFC 4180): a cell containing a comma, a double quote or a line
* break is wrapped in quotes with inner quotes doubled — exactly what
* `parseCsv` reads back.
* - Formula-injection defence: a spreadsheet app treats a cell starting with
* `=`, `+`, `-` or `@` (and, in some apps, a tab or carriage return) as a
* formula, and a customer controls fields such as their name and booking
* notes. Those cells are prefixed with an apostrophe, which Excel, Sheets
* and Numbers show as plain text. Phone numbers that start with `+` get the
* same prefix: a visible apostrophe is a smaller cost than an exploitable
* export.
*/
export type CsvCell = string | number | boolean | Date | null | undefined;
const FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]);
function cellToString(value: CsvCell): string {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value.toISOString();
return String(value);
}
/** One cell, neutralised against formula injection and quoted when needed. */
export function escapeCsvCell(value: CsvCell): string {
let text = cellToString(value);
if (text.length > 0 && FORMULA_TRIGGERS.has(text[0]!)) text = `'${text}`;
if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
}
/** A whole file: UTF-8 byte-order mark (so Excel reads accents correctly), CRLF record separators, trailing newline. */
export function toCsv(rows: CsvCell[][]): string {
return `${rows.map((row) => row.map(escapeCsvCell).join(",")).join("\r\n")}\r\n`;
}
@@ -5,9 +5,11 @@ import {
calendarDateOf,
compareCalendarDates,
eachCalendarDate,
endOfBusinessDay,
isAfterCalendarDate,
isBeforeCalendarDate,
minutesFromMidnight,
startOfBusinessDay,
todayIn,
wallClockToInstant,
weekdayOf,
@@ -96,3 +98,66 @@ describe("todayIn", () => {
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
// The days themselves, not the days either side — the 2026-03 tests above
// never touched a 23h/25h day, which is how wallClockToInstant adding
// *elapsed* minutes to local midnight (an hour off on these days) went
// unnoticed. Europe/London: clocks go back 2026-10-25 02:00 -> 01:00
// (25-hour day) and forward 2027-03-28 01:00 -> 02:00 (23-hour day).
describe("clock-change days (Europe/London)", () => {
const zone = "Europe/London";
const hours = (from: Date, to: Date) => (to.getTime() - from.getTime()) / 3_600_000;
it("autumn change (25h day): a wall-clock time round-trips and lands on the right instant", () => {
const instant = wallClockToInstant("2026-10-25", 10 * 60, zone);
expect(instant.toISOString()).toBe("2026-10-25T10:00:00.000Z"); // GMT again by 10:00
expect(minutesFromMidnight(instant, zone)).toBe(10 * 60);
expect(calendarDateOf(instant, zone)).toBe("2026-10-25");
});
it("spring change (23h day): a wall-clock time round-trips and lands on the right instant", () => {
const instant = wallClockToInstant("2027-03-28", 10 * 60, zone);
expect(instant.toISOString()).toBe("2027-03-28T09:00:00.000Z"); // BST by 10:00
expect(minutesFromMidnight(instant, zone)).toBe(10 * 60);
expect(calendarDateOf(instant, zone)).toBe("2027-03-28");
});
it("every whole-hour wall-clock time from 09:00 to 18:00 round-trips on both days", () => {
for (const date of ["2026-10-25", "2027-03-28"]) {
for (let minutes = 9 * 60; minutes <= 18 * 60; minutes += 30) {
const instant = wallClockToInstant(date, minutes, zone);
expect(minutesFromMidnight(instant, zone)).toBe(minutes);
expect(calendarDateOf(instant, zone)).toBe(date);
}
}
});
it("a business day is 25 hours long when the clocks go back and 23 when they go forward", () => {
expect(hours(startOfBusinessDay("2026-10-25", zone), endOfBusinessDay("2026-10-25", zone))).toBe(25);
expect(hours(startOfBusinessDay("2027-03-28", zone), endOfBusinessDay("2027-03-28", zone))).toBe(23);
expect(hours(startOfBusinessDay("2026-10-24", zone), endOfBusinessDay("2026-10-24", zone))).toBe(24);
});
it("a day ends exactly where the next one starts, so no appointment falls in a gap or two days", () => {
for (const [date, next] of [
["2026-10-25", "2026-10-26"],
["2027-03-28", "2027-03-29"],
["2026-10-24", "2026-10-25"],
] as const) {
expect(endOfBusinessDay(date, zone).toISOString()).toBe(startOfBusinessDay(next, zone).toISOString());
}
expect(endOfBusinessDay("2026-10-25", zone).toISOString()).toBe("2026-10-26T00:00:00.000Z");
expect(endOfBusinessDay("2027-03-28", zone).toISOString()).toBe("2027-03-28T23:00:00.000Z");
});
it("the last evening hour of the 25h day still belongs to that day", () => {
const lateEvening = new Date("2026-10-25T23:30:00.000Z"); // 23:30 GMT
expect(calendarDateOf(lateEvening, zone)).toBe("2026-10-25");
expect(lateEvening < endOfBusinessDay("2026-10-25", zone)).toBe(true);
expect(lateEvening >= startOfBusinessDay("2026-10-25", zone)).toBe(true);
});
it("minutes beyond a day roll into the next calendar date at that date's own offset", () => {
expect(wallClockToInstant("2026-10-24", 24 * 60 + 9 * 60, zone).toISOString()).toBe("2026-10-25T09:00:00.000Z");
});
});
+12 -1
View File
@@ -55,9 +55,20 @@ export function calendarDateOf(instant: Date, zone: string): CalendarDate {
* wall clock against the zone's actual offset for that specific date, not
* a fixed offset — the same "10:00" on either side of a DST transition
* produces different UTC instants, correctly.
*
* The wall-clock fields are SET, never added as elapsed minutes: adding
* 600 minutes to local midnight is "ten hours later", which on the day the
* clocks change is 09:00 or 11:00 on the wall — an hour off. Minutes past
* a day (1440 = the end of the day) roll into the next calendar date, whose
* own offset applies. A wall-clock time that doesn't exist (the skipped
* hour in spring) resolves to the first valid time after it.
*/
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
const dayOffset = Math.floor(minutesFromMidnightValue / (24 * 60));
const withinDay = minutesFromMidnightValue - dayOffset * 24 * 60;
const dt = DateTime.fromISO(date, { zone })
.plus({ days: dayOffset })
.set({ hour: Math.floor(withinDay / 60), minute: withinDay % 60, second: 0, millisecond: 0 });
return dt.toJSDate();
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { parseCsv } from "@/lib/csv/parse-csv";
import {
CUSTOMER_EXPORT_HEADER,
recordRows,
scrubTokens,
serializeCustomerExport,
stripQueryString,
} from "../customer-export-csv";
describe("stripQueryString", () => {
it("removes query strings and fragments, keeping the path", () => {
expect(stripQueryString("/appointments/abc123?token=SECRET")).toBe("/appointments/abc123");
expect(stripQueryString("/appointments/abc123#top")).toBe("/appointments/abc123");
expect(stripQueryString("/appointments")).toBe("/appointments");
});
it("returns null for a missing link", () => {
expect(stripQueryString(null)).toBeNull();
expect(stripQueryString(undefined)).toBeNull();
expect(stripQueryString("")).toBeNull();
});
});
describe("scrubTokens", () => {
it("blanks token values in links inside message text", () => {
const out = scrubTokens("Manage it: https://x.example/appointments/a1?token=SECRETVALUE&x=1 thanks");
expect(out).not.toContain("SECRETVALUE");
expect(out).toContain("thanks");
});
it("passes ordinary text and nulls through", () => {
expect(scrubTokens("See you at 10:00")).toBe("See you at 10:00");
expect(scrubTokens(null)).toBeNull();
});
});
describe("recordRows / serializeCustomerExport", () => {
it("produces one row per field, sharing section, record and date", () => {
const date = new Date("2026-03-01T10:00:00Z");
const rows = recordRows("appointment", "appt_1", date, { status: "COMPLETED", staff: "Leo" });
expect(rows).toEqual([
{ section: "appointment", record: "appt_1", date, field: "status", value: "COMPLETED" },
{ section: "appointment", record: "appt_1", date, field: "staff", value: "Leo" },
]);
});
it("writes the header, then every row in order, and reads back with the project's CSV reader", () => {
const date = new Date("2026-03-01T10:00:00Z");
const rows = [
...recordRows("profile", "c1", date, { first_name: "=cmd|' /C calc'!A0", phone: "+44 7700 900123" }),
...recordRows("appointment", "a1", null, { customer_notes: 'Prefers "short, tidy"\nsides' }),
];
const parsed = parseCsv(serializeCustomerExport(rows));
expect(parsed[0]).toEqual([...CUSTOMER_EXPORT_HEADER]);
expect(parsed).toHaveLength(1 + rows.length);
expect(parsed[1]).toEqual(["profile", "c1", "2026-03-01T10:00:00.000Z", "first_name", "'=cmd|' /C calc'!A0"]);
expect(parsed[2]![4]).toBe("'+44 7700 900123");
expect(parsed[3]).toEqual(["appointment", "a1", "", "customer_notes", 'Prefers "short, tidy"\nsides']);
});
});
@@ -0,0 +1,325 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { parseCsv } from "@/lib/csv/parse-csv";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { exportCustomerData } from "../customer-export.service";
const NOTE_SECRET = "PRIVATE-BARBER-NOTE-9f3a";
const PASSWORD_HASH = "bcrypt-hash-SECRET-77c1";
const PUSH_ENDPOINT = "https://push.example.test/endpoint-SECRET-52d9";
const PUSH_KEY = "p256dh-SECRET-1b8e";
const PUSH_AUTH = "auth-SECRET-6c40";
const GUEST_TOKEN = "guest-token-SECRET-aa21";
const OTP_HASH = "otp-hash-SECRET-4d7f";
const OAUTH_TOKEN = "oauth-access-SECRET-e5b3";
async function makeStaffWithPermission(business: TestBusiness, permission: "MANAGE_BUSINESS_PROFILE" | null) {
const staff = await createTestStaff(business);
if (permission) {
const role = await prisma.role.create({
data: { businessId: business.businessId, name: `Admin-${staff.staffId}`, permissions: { create: [{ permission }] } },
});
await prisma.staffProfile.update({ where: { id: staff.staffId }, data: { roleId: role.id } });
}
return staff;
}
function rowsOf(csv: string) {
const [header, ...records] = parseCsv(csv);
expect(header).toEqual(["section", "record", "date", "field", "value"]);
return records.map(([section, record, date, field, value]) => ({ section, record, date, field, value }));
}
describe("exportCustomerData (integration)", () => {
let business: TestBusiness;
let otherBusiness: TestBusiness;
let extraProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
otherBusiness = await createTestBusiness();
extraProfileIds = [];
});
afterEach(async () => {
await prisma.auditLog.deleteMany({ where: { action: "CUSTOMER_DATA_EXPORTED", entityType: "CustomerProfile" } }).catch(() => {});
await prisma.emailOtp.deleteMany({ where: { email: { startsWith: "export-otp-" } } }).catch(() => {});
await cleanupTestBusiness(business);
await cleanupTestBusiness(otherBusiness);
await prisma.customerProfile.deleteMany({ where: { id: { in: extraProfileIds } } }).catch(() => {});
});
/** A registered customer with a little of everything, including every kind of data that must never be exported. */
async function seedFullCustomer() {
const barber = await makeStaffWithPermission(business, null);
const customer = await createTestCustomer(business);
await prisma.user.update({
where: { id: customer.userId },
data: {
passwordHash: PASSWORD_HASH,
emailVerified: new Date("2026-01-02T09:00:00Z"),
phone: `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`,
phoneVerified: new Date("2026-01-03T09:00:00Z"),
},
});
await prisma.account.create({
data: {
userId: customer.userId,
type: "oauth",
provider: "google",
providerAccountId: `g-${customer.userId}`,
access_token: OAUTH_TOKEN,
},
});
await prisma.pushSubscription.create({
data: {
businessId: business.businessId,
userId: customer.userId,
customerId: customer.customerId,
endpoint: `${PUSH_ENDPOINT}-${customer.userId}`,
p256dhKey: PUSH_KEY,
authKey: PUSH_AUTH,
},
});
const email = (await prisma.user.findUniqueOrThrow({ where: { id: customer.userId } })).email!;
await prisma.emailOtp.create({
data: { email: `export-otp-${email}`, codeHash: OTP_HASH, purpose: "SIGN_IN", expiresAt: new Date(Date.now() + 60_000) },
});
await prisma.customerNote.create({
data: { customerId: customer.customerId, authorStaffId: barber.staffId, body: NOTE_SECRET },
});
const { appointmentId } = await createTestAppointment(business, {
customerId: customer.customerId,
staffId: barber.staffId,
startAt: new Date("2026-11-02T10:00:00Z"),
});
await prisma.appointment.update({
where: { id: appointmentId },
data: {
status: "CANCELLED",
notes: "Please use the side door",
cancellationReason: "Feeling unwell",
cancelledAt: new Date("2026-11-01T08:00:00Z"),
},
});
await prisma.appointmentService.createMany({
data: [
{ appointmentId, serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 },
{ appointmentId, serviceId: business.beardTrimServiceId, priceAtBooking: 10, durationAtBookingMinutes: 15 },
],
});
await prisma.guestAppointmentAccessToken.create({
data: { appointmentId, tokenHash: GUEST_TOKEN, expiresAt: new Date(Date.now() + 86_400_000) },
});
// An appointment at the OTHER business must never appear.
const otherBarber = await makeStaffWithPermission(otherBusiness, null);
await createTestAppointment(otherBusiness, {
customerId: customer.customerId,
staffId: otherBarber.staffId,
startAt: new Date("2026-11-09T10:00:00Z"),
});
await prisma.appointment.updateMany({
where: { businessId: otherBusiness.businessId },
data: { notes: "OTHER-BUSINESS-APPOINTMENT" },
});
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, isEnabled: true, stampsRequired: 5 },
});
const account = await prisma.loyaltyAccount.create({
data: { customerId: customer.customerId, loyaltyProgrammeId: programme.id, currentStamps: 3 },
});
await prisma.loyaltyTransaction.create({
data: { loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1, reason: "Visit", authoredByStaffId: barber.staffId },
});
await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 10, label: "10% off", status: "AVAILABLE" },
});
await prisma.notification.create({
data: {
businessId: business.businessId,
recipientUserId: customer.userId,
recipientCustomerId: customer.customerId,
type: "BOOKING_CONFIRMED",
appointmentId,
subject: "Your booking is confirmed",
bodyText: "See you on 2 November",
deepLinkPath: `/appointments/${appointmentId}?token=LINKTOKEN-SECRET-93e0`,
idempotencyKey: `export-test-${customer.customerId}`,
},
});
await prisma.notificationPreference.create({
data: { userId: customer.userId, type: "BOOKING_REMINDER", channel: "EMAIL", isEnabled: false },
});
return { ...customer, barber, appointmentId };
}
it("exports the customer's data completely and leaves out everything private", async () => {
const customer = await seedFullCustomer();
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
const result = await exportCustomerData(prisma, {
actorUserId: admin.userId,
businessId: business.businessId,
customerId: customer.customerId,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.filename).toMatch(/^customer-data-test-customer-\d{4}-\d{2}-\d{2}\.csv$/);
// Nothing private, anywhere in the file.
for (const secret of [
NOTE_SECRET,
PASSWORD_HASH,
PUSH_ENDPOINT,
PUSH_KEY,
PUSH_AUTH,
GUEST_TOKEN,
OTP_HASH,
OAUTH_TOKEN,
"LINKTOKEN-SECRET",
"OTHER-BUSINESS-APPOINTMENT",
]) {
expect(result.csv).not.toContain(secret);
}
const rows = rowsOf(result.csv);
const value = (section: string, field: string, record?: string) =>
rows.find((r) => r.section === section && r.field === field && (record === undefined || r.record === record))?.value;
// Profile and linked account, including verified dates.
expect(value("profile", "first_name")).toBe("Test");
expect(value("profile", "last_name")).toBe("Customer");
expect(value("profile", "registered_customer")).toBe("true");
expect(value("account", "email_verified_at")).toBe("2026-01-02T09:00:00.000Z");
expect(value("account", "phone_verified_at")).toBe("2026-01-03T09:00:00.000Z");
expect(value("account", "phone")).toMatch(/^'\+44 7700 /); // formula-guard prefix on a leading "+"
// The appointment, with the customer's own notes and the cancellation reason — one appointment, this business only.
expect(rows.filter((r) => r.section === "appointment" && r.field === "status")).toHaveLength(1);
expect(value("appointment", "status", customer.appointmentId)).toBe("CANCELLED");
expect(value("appointment", "services", customer.appointmentId)).toBe("Haircut + Beard Trim");
expect(value("appointment", "staff", customer.appointmentId)).toBe("Test Barber");
expect(value("appointment", "price", customer.appointmentId)).toBe("30.00");
expect(value("appointment", "customer_notes", customer.appointmentId)).toBe("Please use the side door");
expect(value("appointment", "cancellation_reason", customer.appointmentId)).toBe("Feeling unwell");
// Loyalty.
expect(value("loyalty_account", "current_stamps")).toBe("3");
expect(value("loyalty_transaction", "type")).toBe("STAMP_ADDED");
expect(value("loyalty_transaction", "reason")).toBe("Visit");
expect(value("loyalty_reward", "label")).toBe("10% off");
// Notifications sent: subject and body, link without its query string.
expect(value("notification", "subject")).toBe("Your booking is confirmed");
expect(value("notification", "message")).toBe("See you on 2 November");
expect(value("notification", "link")).toBe(`/appointments/${customer.appointmentId}`);
// Preferences.
const prefFields = rows.filter((r) => r.section === "notification_preference");
expect(prefFields.map((r) => [r.field, r.value])).toEqual([
["type", "BOOKING_REMINDER"],
["channel", "EMAIL"],
["enabled", "false"],
]);
});
it("writes a CUSTOMER_DATA_EXPORTED audit entry without any of the exported data in it", async () => {
const customer = await seedFullCustomer();
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
const result = await exportCustomerData(prisma, {
actorUserId: admin.userId,
businessId: business.businessId,
customerId: customer.customerId,
});
expect(result.ok).toBe(true);
const entries = await prisma.auditLog.findMany({ where: { action: "CUSTOMER_DATA_EXPORTED", entityId: customer.customerId } });
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ actorUserId: admin.userId, entityType: "CustomerProfile" });
expect(JSON.stringify(entries[0]!.metadata)).not.toContain("Feeling unwell");
});
it("exports a guest customer (no linked account) without account or preference sections", async () => {
const barber = await makeStaffWithPermission(business, null);
const guest = await createTestGuestCustomer();
extraProfileIds.push(guest.customerId);
await createTestAppointment(business, { customerId: guest.customerId, staffId: barber.staffId });
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
const result = await exportCustomerData(prisma, {
actorUserId: admin.userId,
businessId: business.businessId,
customerId: guest.customerId,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const sections = new Set(rowsOf(result.csv).map((r) => r.section));
expect(sections).toEqual(new Set(["profile", "appointment"]));
});
describe("permission and scope", () => {
it("refuses a barber without MANAGE_BUSINESS_PROFILE, even though they can open the client page, and records nothing", async () => {
const customer = await seedFullCustomer();
const result = await exportCustomerData(prisma, {
actorUserId: customer.barber.userId,
businessId: business.businessId,
customerId: customer.customerId,
});
expect(result).toEqual({ ok: false, reason: "FORBIDDEN" });
expect(await prisma.auditLog.count({ where: { action: "CUSTOMER_DATA_EXPORTED", entityId: customer.customerId } })).toBe(0);
});
it("refuses a customer (non-staff) user", async () => {
const customer = await seedFullCustomer();
const result = await exportCustomerData(prisma, {
actorUserId: customer.userId,
businessId: business.businessId,
customerId: customer.customerId,
});
expect(result).toEqual({ ok: false, reason: "FORBIDDEN" });
});
it("refuses an administrator of a different business", async () => {
const customer = await seedFullCustomer();
const outsider = await makeStaffWithPermission(otherBusiness, "MANAGE_BUSINESS_PROFILE");
const result = await exportCustomerData(prisma, {
actorUserId: outsider.userId,
businessId: business.businessId,
customerId: customer.customerId,
});
expect(result).toEqual({ ok: false, reason: "FORBIDDEN" });
});
it("returns NOT_FOUND for a customer who is not one of this business's clients", async () => {
const stranger = await createTestGuestCustomer(); // a guest with no booking here
extraProfileIds.push(stranger.customerId);
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
const result = await exportCustomerData(prisma, {
actorUserId: admin.userId,
businessId: business.businessId,
customerId: stranger.customerId,
});
expect(result).toEqual({ ok: false, reason: "NOT_FOUND" });
expect(await prisma.auditLog.count({ where: { action: "CUSTOMER_DATA_EXPORTED", entityId: stranger.customerId } })).toBe(0);
});
});
});
@@ -0,0 +1,47 @@
import { toCsv } from "@/lib/csv/write-csv";
import type { CsvCell } from "@/lib/csv/write-csv";
/**
* The database-free half of the customer data export: the row shape, the
* helpers that build and sanitise rows, and the CSV serialiser. Split out of
* customer-export.service.ts so it can be unit tested without a database —
* see that file for what the export contains and, more importantly, what it
* must never contain.
*/
export const CUSTOMER_EXPORT_HEADER = ["section", "record", "date", "field", "value"] as const;
export interface CustomerExportRow {
section: string;
record: string;
date: Date | null;
field: string;
value: CsvCell;
}
export type Fields = Record<string, CsvCell>;
/** Turns `{field: value}` pairs into rows of one record. Empty values are kept (as blank cells) so the shape of every record is the same. */
export function recordRows(section: string, record: string, date: Date | null, fields: Fields): CustomerExportRow[] {
return Object.entries(fields).map(([field, value]) => ({ section, record, date, field, value }));
}
/** Notification links carry single-purpose access tokens in their query string — drop everything after the path. */
export function stripQueryString(path: string | null | undefined): string | null {
if (!path) return null;
return path.split(/[?#]/)[0] || null;
}
/** A link with a token could also appear inside the message text; blank out any `token=…` value. */
export function scrubTokens(text: string | null | undefined): string | null {
if (text == null) return null;
return text.replace(/([?&]token=)[^\s&"'<>]+/gi, "$1[removed]");
}
export function serializeCustomerExport(rows: CustomerExportRow[]): string {
return toCsv([
[...CUSTOMER_EXPORT_HEADER],
...rows.map((r) => [r.section, r.record, r.date, r.field, r.value]),
]);
}
@@ -0,0 +1,271 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Permission } from "@/generated/prisma/enums";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { clientDirectoryWhere } from "./client-directory";
import { recordRows, scrubTokens, serializeCustomerExport, stripQueryString } from "./customer-export-csv";
import type { CustomerExportRow } from "./customer-export-csv";
/**
* One customer's personal data as a single CSV — the "give me everything you
* hold on me" request, run by the business owner on the customer's behalf.
*
* Long format with a `section` column (profile, account, appointment,
* loyalty_*, notification, notification_preference): every row is one field
* of one record, so very different record shapes share one header and the
* file still opens as an ordinary spreadsheet. `record` groups the fields of
* one item (an appointment id, a notification id); `date` is the record's own
* timestamp.
*
* What is deliberately NEVER read here (not just filtered afterwards — the
* queries below use explicit `select`s, so a new column added to User or
* Notification later does not leak into exports by accident):
* - CustomerNote — the barbers' private notes about the client.
* - PushSubscription endpoints/keys, passwordHash, OAuth Account/Session
* tokens, EmailOtp codes, GuestAppointmentAccessToken, AuditLog.
* - Which staff member authored a loyalty entry, internal ids of staff.
*/
export type ExportCustomerDataResult =
| { ok: true; csv: string; filename: string; rowCount: number }
| { ok: false; reason: "FORBIDDEN" | "NOT_FOUND" };
export async function buildCustomerExportRows(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
): Promise<CustomerExportRow[] | null> {
const { customerId, businessId } = params;
const profile = await prisma.customerProfile.findFirst({
where: { AND: [{ id: customerId }, clientDirectoryWhere(businessId)] },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
isGuest: true,
createdAt: true,
updatedAt: true,
preferredBarber: { select: { user: { select: { name: true } } } },
user: {
select: {
id: true,
name: true,
email: true,
emailVerified: true,
phone: true,
phoneVerified: true,
createdAt: true,
},
},
},
});
if (!profile) return null;
const [appointments, loyaltyAccounts, notifications, preferences] = await Promise.all([
prisma.appointment.findMany({
where: { customerId, businessId },
orderBy: { startAt: "asc" },
select: {
id: true,
status: true,
startAt: true,
endAt: true,
notes: true,
discountAmount: true,
cancellationReason: true,
cancelledAt: true,
createdAt: true,
staff: { select: { user: { select: { name: true } } } },
services: { select: { priceAtBooking: true, service: { select: { name: true } } } },
},
}),
prisma.loyaltyAccount.findMany({
where: { customerId, loyaltyProgramme: { businessId } },
select: {
id: true,
currentStamps: true,
createdAt: true,
transactions: {
orderBy: { createdAt: "asc" },
select: { id: true, type: true, amount: true, reason: true, createdAt: true },
},
rewards: {
orderBy: { createdAt: "asc" },
select: {
id: true,
label: true,
rewardType: true,
value: true,
status: true,
expiresAt: true,
redeemedAt: true,
createdAt: true,
},
},
},
}),
prisma.notification.findMany({
where: {
businessId,
OR: [{ recipientCustomerId: customerId }, ...(profile.user ? [{ recipientUserId: profile.user.id }] : [])],
},
orderBy: { createdAt: "asc" },
select: { id: true, type: true, subject: true, bodyText: true, deepLinkPath: true, readAt: true, createdAt: true },
}),
profile.user
? prisma.notificationPreference.findMany({
where: { userId: profile.user.id },
orderBy: [{ type: "asc" }, { channel: "asc" }],
select: { id: true, type: true, channel: true, isEnabled: true },
})
: Promise.resolve([]),
]);
const rows: CustomerExportRow[] = [];
rows.push(
...recordRows("profile", profile.id, profile.createdAt, {
first_name: profile.firstName,
last_name: profile.lastName,
email: profile.email,
phone: profile.phone,
registered_customer: !profile.isGuest,
preferred_barber: profile.preferredBarber?.user.name ?? null,
last_updated: profile.updatedAt,
}),
);
if (profile.user) {
rows.push(
...recordRows("account", profile.user.id, profile.user.createdAt, {
name: profile.user.name,
email: profile.user.email,
email_verified_at: profile.user.emailVerified,
phone: profile.user.phone,
phone_verified_at: profile.user.phoneVerified,
}),
);
}
for (const a of appointments) {
const subtotal = a.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
rows.push(
...recordRows("appointment", a.id, a.startAt, {
status: a.status,
starts_at: a.startAt,
ends_at: a.endAt,
services: a.services.map((s) => s.service.name).join(" + "),
staff: a.staff.user.name,
price: (subtotal - discount).toFixed(2),
discount: a.discountAmount ? discount.toFixed(2) : null,
customer_notes: a.notes,
cancellation_reason: a.cancellationReason,
cancelled_at: a.cancelledAt,
booked_at: a.createdAt,
}),
);
}
for (const account of loyaltyAccounts) {
rows.push(
...recordRows("loyalty_account", account.id, account.createdAt, { current_stamps: account.currentStamps }),
);
for (const t of account.transactions) {
rows.push(
...recordRows("loyalty_transaction", t.id, t.createdAt, { type: t.type, amount: t.amount, reason: t.reason }),
);
}
for (const r of account.rewards) {
rows.push(
...recordRows("loyalty_reward", r.id, r.createdAt, {
label: r.label,
reward_type: r.rewardType,
value: r.value ? Number(r.value).toFixed(2) : null,
status: r.status,
expires_at: r.expiresAt,
redeemed_at: r.redeemedAt,
}),
);
}
}
for (const n of notifications) {
rows.push(
...recordRows("notification", n.id, n.createdAt, {
type: n.type,
subject: scrubTokens(n.subject),
message: scrubTokens(n.bodyText),
link: stripQueryString(n.deepLinkPath),
read_at: n.readAt,
}),
);
}
for (const p of preferences) {
rows.push(
...recordRows("notification_preference", p.id, null, {
type: p.type,
channel: p.channel,
enabled: p.isEnabled,
}),
);
}
return rows;
}
function filenameFor(firstName: string, lastName: string, now: Date): string {
const slug = `${firstName}-${lastName}`
.normalize("NFKD")
.replace(/[^\w-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase();
return `customer-data-${slug || "export"}-${now.toISOString().slice(0, 10)}.csv`;
}
/**
* The whole operation behind the export route: checks the caller may export
* (MANAGE_BUSINESS_PROFILE — the same bar as the client import — and that they
* work for `businessId`; a Super Administrator may act on any), builds the CSV
* and records an audit entry. The permission check lives here, not in the
* route, so no caller can reach the data without it; and it is independent of
* who can open the client's detail page (any staff member).
*/
export async function exportCustomerData(
prisma: PrismaClient,
params: { actorUserId: string; businessId: string; customerId: string; now?: Date },
): Promise<ExportCustomerDataResult> {
const { actorUserId, businessId, customerId } = params;
if (!(await hasPermission(actorUserId, Permission.MANAGE_BUSINESS_PROFILE))) {
return { ok: false, reason: "FORBIDDEN" };
}
if (!(await isSuperAdmin(actorUserId))) {
const staff = await prisma.staffProfile.findUnique({ where: { userId: actorUserId }, select: { businessId: true } });
if (staff?.businessId !== businessId) return { ok: false, reason: "FORBIDDEN" };
}
const rows = await buildCustomerExportRows(prisma, { customerId, businessId });
if (!rows) return { ok: false, reason: "NOT_FOUND" };
await writeAuditLog(prisma, {
actorUserId,
action: "CUSTOMER_DATA_EXPORTED",
entityType: "CustomerProfile",
entityId: customerId,
metadata: { businessId, rowCount: rows.length },
});
const first = rows.find((r) => r.section === "profile" && r.field === "first_name")?.value;
const last = rows.find((r) => r.section === "profile" && r.field === "last_name")?.value;
return {
ok: true,
csv: serializeCustomerExport(rows),
filename: filenameFor(String(first ?? ""), String(last ?? ""), params.now ?? new Date()),
rowCount: rows.length,
};
}
@@ -0,0 +1,183 @@
import { DateTime } from "luxon";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import {
addDays,
calendarDateOf,
endOfBusinessDay,
minutesFromMidnight,
startOfBusinessDay,
todayIn,
wallClockToInstant,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
getAvailableDates,
getAvailableTimeSlots,
validateBookingSlot,
} from "../availability.service";
import { createBooking } from "../booking.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "./fixtures";
import type { TestBusiness } from "./fixtures";
/**
* The next days (within the booking window) on which the clocks change in
* `zone` — found from the zone's own rules, so this keeps testing real
* 23h/25h days whenever it runs instead of going stale after a fixed date.
*/
function upcomingClockChangeDays(zone: string, count: number): CalendarDate[] {
const found: CalendarDate[] = [];
let date = addDays(todayIn(zone), 1);
for (let i = 0; i < 365 && found.length < count; i++) {
const hoursInDay =
(DateTime.fromISO(addDays(date, 1), { zone }).toMillis() -
DateTime.fromISO(date, { zone }).toMillis()) /
3_600_000;
if (hoursInDay !== 24) found.push(date);
date = addDays(date, 1);
}
return found;
}
const ALL_DAYS = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
describe("booking on the days the clocks change (integration)", () => {
let business: TestBusiness;
let staffId: string;
let customerId: string;
let changeDays: CalendarDate[];
beforeEach(async () => {
business = await createTestBusiness();
// Clock changes fall on a Sunday in Europe/London, which the fixture business keeps closed.
await prisma.businessHours.updateMany({
where: { businessId: business.businessId },
data: { isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
});
staffId = (await createTestStaff(business, { workingDays: ALL_DAYS })).staffId;
customerId = (await createTestCustomer(business)).customerId;
changeDays = upcomingClockChangeDays(business.timezone, 2);
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("finds both an autumn (25h) and a spring (23h) change in the next year", () => {
expect(changeDays).toHaveLength(2);
const lengths = changeDays.map(
(d) =>
(endOfBusinessDay(d, business.timezone).getTime() -
startOfBusinessDay(d, business.timezone).getTime()) /
3_600_000,
);
expect(lengths.sort()).toEqual([23, 25]);
});
it("offers slots whose instants match the wall-clock time shown, on both changes", async () => {
for (const date of changeDays) {
const { slots, validation } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date,
});
expect(validation.valid).toBe(true);
expect(slots[0]!.startMinutes).toBe(9 * 60);
for (const slot of slots) {
const startAt = new Date(slot.startAt);
expect(minutesFromMidnight(startAt, business.timezone)).toBe(slot.startMinutes);
expect(calendarDateOf(startAt, business.timezone)).toBe(date);
// A 30-minute haircut is 30 real minutes long.
expect(new Date(slot.endAt).getTime() - startAt.getTime()).toBe(30 * 60_000);
}
const { dates } = await getAvailableDates(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
fromDate: date,
toDate: date,
});
expect(dates).toEqual([date]);
}
});
it("books 10:00 at exactly 10:00 local time and keeps the booking's length, on both changes", async () => {
for (const date of changeDays) {
const startAt = wallClockToInstant(date, 10 * 60, business.timezone);
expect(
(
await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt,
})
).validation.valid,
).toBe(true);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(minutesFromMidnight(result.appointment.startAt, business.timezone)).toBe(10 * 60);
expect(calendarDateOf(result.appointment.startAt, business.timezone)).toBe(date);
expect(result.appointment.endAt.getTime() - result.appointment.startAt.getTime()).toBe(
30 * 60_000,
);
}
});
it("an evening booking on the 25h day still counts as that day, and is not double-booked", async () => {
const date = changeDays.find(
(d) =>
endOfBusinessDay(d, business.timezone).getTime() -
startOfBusinessDay(d, business.timezone).getTime() ===
25 * 3_600_000,
)!;
const startAt = wallClockToInstant(date, 17 * 60 + 30, business.timezone);
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(first.success).toBe(true);
const second = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt,
});
expect(second.validation.valid).toBe(false);
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date,
});
expect(slots.map((s) => s.startMinutes)).not.toContain(17 * 60 + 30);
});
});
@@ -9,6 +9,8 @@ import {
prisma,
} from "./fixtures";
import type { TestBusiness } from "./fixtures";
import { PermissionDeniedError, assertCanAccessAppointment } from "@/features/booking/authorization";
import { getAppointmentById } from "../appointment.service";
import {
customerAppointmentLink,
deriveGuestAccessToken,
@@ -81,4 +83,50 @@ describe("guest access links (integration)", () => {
const { userId } = await createTestCustomer(business);
expect(customerAppointmentLink({ id: appointmentId, customer: { userId } })).toBe(`/appointments/${appointmentId}`);
});
describe("assertCanAccessAppointment on a guest appointment", () => {
it("an appointment id alone is rejected — no token, an empty token, a guessed token, or someone else's session", async () => {
const { appointmentId } = await guestAppointment();
const appointment = (await getAppointmentById(prisma, appointmentId))!;
const otherUser = await createTestCustomer(business);
for (const access of [
{},
{ guestToken: "" },
{ guestToken: "not-the-token" },
{ sessionUserId: otherUser.userId },
{ sessionUserId: otherUser.userId, guestToken: "not-the-token" },
]) {
await expect(assertCanAccessAppointment(prisma, appointment, access)).rejects.toThrow(PermissionDeniedError);
}
});
it("one appointment's token does not open a different guest appointment", async () => {
const first = await guestAppointment();
const second = await guestAppointment();
const secondAppointment = (await getAppointmentById(prisma, second.appointmentId))!;
await expect(
assertCanAccessAppointment(prisma, secondAppointment, { guestToken: first.bookingToken }),
).rejects.toThrow(PermissionDeniedError);
await expect(
assertCanAccessAppointment(prisma, secondAppointment, { guestToken: deriveGuestAccessToken(first.appointmentId) }),
).rejects.toThrow(PermissionDeniedError);
});
it("the booking-time token and the derived link token both open it; a revoked appointment opens for neither", async () => {
const { appointmentId, bookingToken } = await guestAppointment();
const appointment = (await getAppointmentById(prisma, appointmentId))!;
await expect(assertCanAccessAppointment(prisma, appointment, { guestToken: bookingToken })).resolves.toBeUndefined();
await expect(
assertCanAccessAppointment(prisma, appointment, { guestToken: deriveGuestAccessToken(appointmentId) }),
).resolves.toBeUndefined();
await revokeGuestAccessToken(prisma, appointmentId);
await expect(assertCanAccessAppointment(prisma, appointment, { guestToken: bookingToken })).rejects.toThrow(
PermissionDeniedError,
);
});
});
});
@@ -58,6 +58,10 @@ export interface ListAppointmentsParams {
fromDate?: CalendarDate;
toDate?: CalendarDate;
businessTimezone?: string; // required if fromDate/toDate given
/** Sort by start time. Defaults to "asc" — staff agendas and reminders rely on it. */
order?: "asc" | "desc";
/** Cap the number of rows returned (for paged lists). */
take?: number;
}
export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
@@ -80,7 +84,8 @@ export async function listAppointments(prisma: PrismaClient, params: ListAppoint
...dateFilter,
},
include: APPOINTMENT_INCLUDE,
orderBy: { startAt: "asc" },
orderBy: [{ startAt: params.order ?? "asc" }, { id: "asc" }],
...(params.take !== undefined ? { take: params.take } : {}),
});
}
@@ -398,7 +398,8 @@ export async function getAvailableTimeSlots(
startMinutes,
endMinutes,
startAt: startAt.toISOString(),
endAt: wallClockToInstant(params.date, endMinutes, context.business.timezone).toISOString(),
// Elapsed, not wall-clock: a service is a real number of minutes long, even across a clock change.
endAt: new Date(startAt.getTime() + context.totalDurationMinutes * 60_000).toISOString(),
staffOptions: [staffId],
});
}
@@ -452,7 +453,9 @@ export async function validateBookingSlot(
const date = calendarDateOf(params.startAt, timezone);
const startMinutes = minutesFromMidnight(params.startAt, timezone);
const endMinutes = startMinutes + context.totalDurationMinutes;
const endAt = wallClockToInstant(date, endMinutes, timezone);
// Elapsed, not wall-clock: the booking lasts exactly the services' duration even across a clock change.
// `endMinutes` stays wall-clock — it is only compared against business hours and breaks, which are.
const endAt = new Date(params.startAt.getTime() + context.totalDurationMinutes * 60_000);
const now = new Date();
const today = todayIn(timezone);
+2
View File
@@ -478,6 +478,8 @@ export function validateTimeOffInput(input: TimeOffInput): ValidationResult<Staf
});
}
if (
!Number.isInteger(input.startMinutes) ||
!Number.isInteger(input.endMinutes) ||
input.startMinutes! < 0 ||
input.endMinutes! > 24 * 60 ||
input.startMinutes! >= input.endMinutes!