Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import Link from "next/link";
import { prisma } from "@/lib/db/prisma";
import { validateInvitationToken } from "@/services/staff/staff-invitation.service";
import { AcceptInvitationForm } from "@/features/auth/components/accept-invitation-form";
export const metadata: Metadata = { title: "Accept Invitation" };
export const dynamic = "force-dynamic";
export default async function AcceptInvitationPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
if (!token) {
return <InvalidInvitation message="This invitation link is missing its token." />;
}
const check = await validateInvitationToken(prisma, token);
if (!check.valid) {
const message =
check.reason === "EXPIRED"
? "This invitation link has expired. Ask the business owner to send you a new one."
: check.reason === "ALREADY_ACCEPTED"
? "This invitation has already been used."
: "This invitation link isn't valid.";
return <InvalidInvitation message={message} />;
}
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Welcome, {check.invitation.staff.user.name}</h1>
<p className="text-muted mb-10">Finish setting up your staff account.</p>
<AcceptInvitationForm token={token} businessName={check.invitation.staff.business.name} />
</div>
);
}
function InvalidInvitation({ message }: { message: string }) {
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Invitation Not Available</h1>
<p className="text-muted mb-10">{message}</p>
<Link href="/sign-in" className="border-ink text-ink border-b font-bold text-sm">
Go to Sign In
</Link>
</div>
);
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
/**
* Owner is a permission on a staff account, not a separate account type
@@ -20,5 +21,9 @@ export default async function OwnerLayout({ children }: { children: React.ReactN
redirect("/staff");
}
return <StaffShell>{children}</StaffShell>;
return (
<StaffShell items={OWNER_NAV_ITEMS} footerLink={{ href: "/staff", label: "Barber Tools" }}>
{children}
</StaffShell>
);
}
@@ -0,0 +1,268 @@
import type { Metadata } from "next";
import { CalendarPlus, Mail, Phone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function OwnerAppointmentDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, timezone: true } } },
});
if (!owner) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: appointment.customerId },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.loyaltyTransaction.findMany({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: owner.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
: null,
]);
const tz = owner.business.timezone;
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
const total = subtotal - discount;
const canRedeemReward =
loyalty?.availableReward &&
appointment.discountAmount === null &&
appointment.status !== "CANCELLED";
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link href={`/owner/team/${appointment.staffId}`} className="text-muted text-xs font-bold tracking-widest uppercase underline">
{appointment.staff.user.name ?? "Staff"}
</Link>
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${appointment.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Services</span>
<p className="font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Date</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Time</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })} –{" "}
{appointment.endAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Duration</span>
<p className="font-bold">{totalDuration} min</p>
</div>
</div>
<div className="border-line border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted">Subtotal</span>
<span className="font-bold">£{subtotal.toFixed(2)}</span>
</div>
{discount > 0 && (
<div className="text-accent-loyalty flex items-center justify-between">
<span>Loyalty reward</span>
<span className="font-bold">-£{discount.toFixed(2)}</span>
</div>
)}
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
<span className="font-bold">Total</span>
<span className="font-bold">£{total.toFixed(2)}</span>
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Booking Notes</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Customer Notes</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Cancellation</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">&ldquo;{appointment.cancellationReason}&rdquo;</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/owner/appointments/${appointment.id}/reschedule`}
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule / Reassign
</Link>
)}
{appointment.status === "COMPLETED" && <BookFollowUpButton appointmentId={appointment.id} />}
</div>
<AppointmentStatusActions
appointmentId={appointment.id}
status={appointment.status}
actions={ownerAppointmentActions}
/>
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />}
{canRedeemReward && loyalty.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={subtotal}
/>
)}
</div>
</main>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
import type { Metadata } from "next";
import { CalendarX2, Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
todayIn,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar";
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "All-Staff Calendar" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
function formatRange(startMinutes: number, endMinutes: number): string {
const fmt = (m: number) => {
const h = Math.floor(m / 60);
const min = m % 60;
const period = h >= 12 ? "PM" : "AM";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
};
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
}
export default async function OwnerCalendarPage({
searchParams,
}: {
searchParams: Promise<{ date?: string; view?: string; staffId?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/calendar");
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, name: true, timezone: true } } },
});
if (!owner) redirect("/staff");
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
const today = todayIn(owner.business.timezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const businessTimezone = owner.business.timezone;
const allStaff = await prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
include: { user: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
});
const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined;
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
listAppointments(prisma, {
businessId: owner.businessId,
staffId: selectedStaff?.id,
fromDate: date,
toDate: date,
businessTimezone,
}),
prisma.workingHours.findMany({
where: { staff: { businessId: owner.businessId }, dayOfWeek: weekday },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staff: { businessId: owner.businessId } } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: owner.businessId },
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
]);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: owner.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
function buildColumnFor(staffId: string) {
const workingHours = workingHoursAll.find((h) => h.staffId === staffId);
const workingWindow =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
const blocks: CalendarBlock[] = [
...breakDaysAll
.filter((bd) => bd.break.staffId === staffId)
.map((bd) => ({
id: `break-${bd.id}`,
startMinutes: bd.break.startMinutes,
endMinutes: bd.break.endMinutes,
label: bd.break.label,
kind: "break" as const,
})),
...timeOffAll
.filter((t) => t.staffId === staffId && t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({
id: `timeoff-${t.id}`,
startMinutes: t.startMinutes!,
endMinutes: t.endMinutes!,
label: t.reason ?? "Blocked",
kind: "timeOff" as const,
})),
];
const wholeDayOff = timeOffAll.some((t) => t.staffId === staffId && t.startMinutes === null);
const staffAppointments: CalendarAppointment[] = appointments
.filter((a) => a.staffId === staffId)
.map((a) => ({
id: a.id,
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
serviceNames: a.services.map((s) => s.service.name).join(" + "),
status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
}));
return { workingWindow, blocks, wholeDayOff, staffAppointments };
}
return (
<div className="pb-6">
<div className="flex items-center justify-between px-6 pt-12">
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
<div className="flex gap-2">
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
<Button asChild size="sm">
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
</div>
<DateStrip
selectedDate={date}
businessTimezone={businessTimezone}
basePath="/owner/calendar"
view={view}
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
/>
<div className="mt-4 flex flex-wrap items-center gap-2 px-6">
<Link
href={`/owner/calendar?date=${date}&view=${view}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
!selectedStaff ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
All Staff
</Link>
{allStaff.map((s) => (
<Link
key={s.id}
href={`/owner/calendar?date=${date}&view=${view}&staffId=${s.id}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
selectedStaff?.id === s.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{s.user.name}
</Link>
))}
</div>
<div className="mt-4 flex gap-2 px-6">
<Link
href={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Day
</Link>
<Link
href={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Agenda
</Link>
</div>
<div className="mt-4">
{view === "day" ? (
staffToRender.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
</div>
) : (
<div className="flex gap-4 overflow-x-auto px-6 pb-4 lg:px-0">
{staffToRender.map((s) => {
const column = buildColumnFor(s.id);
return (
<div key={s.id} className="w-[320px] shrink-0 lg:w-auto lg:flex-1">
<p className="text-muted mb-2 px-6 text-xs font-bold tracking-widest uppercase lg:px-0">
{s.user.name}
</p>
{column.wholeDayOff ? (
<div className="px-6 lg:px-0">
<EmptyState icon={CalendarX2} title="Off this day" />
</div>
) : (
<DayCalendar
appointments={column.staffAppointments}
blocks={column.blocks}
workingWindow={column.workingWindow}
currentTimeMinutes={currentTimeMinutes}
appointmentHrefBase="/owner/appointments"
/>
)}
</div>
);
})}
</div>
)
) : appointments.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No appointments" description="Nothing scheduled for this day." />
</div>
) : (
<div className="space-y-4 px-6 lg:mx-auto lg:max-w-3xl">
{[...appointments]
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
.map((a) => (
<div key={a.id}>
{!selectedStaff && (
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">
{a.staff.user.name}
</p>
)}
<AppointmentCard
status={STATUS_MAP[a.status] ?? "confirmed"}
serviceName={a.services.map((s) => s.service.name).join(" + ")}
person={{ id: a.customerId, name: `${a.customer.firstName} ${a.customer.lastName}` }}
personLabel="for"
formattedTime={formatRange(
minutesFromMidnight(a.startAt, businessTimezone),
minutesFromMidnight(a.endAt, businessTimezone),
)}
hasNotes={!!a.notes}
hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
actions={
<Link
href={`/owner/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}
+184 -4
View File
@@ -1,9 +1,189 @@
import type { Metadata } from "next";
import { ChartLine } from "lucide-react";
import { ComingSoon } from "@/components/feedback/coming-soon";
import Link from "next/link";
import {
AlertTriangle,
Calendar,
CalendarX2,
CheckCheck,
Gift,
Stamp,
TrendingUp,
UserX,
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
export const metadata: Metadata = { title: "Business Dashboard" };
export const dynamic = "force-dynamic";
export default function OwnerDashboardPage() {
return <ComingSoon icon={ChartLine} title="Business Dashboard" phase="Phase 4: Owner Administration" />;
function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; label: string; value: string | number; hint?: string }) {
return (
<Card className="p-5">
<div className="text-muted mb-2 flex items-center gap-2">
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
</div>
<p className="text-2xl font-bold">{value}</p>
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
</Card>
);
}
export default async function OwnerDashboardPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, name: true, timezone: true } } },
});
if (!staff) redirect("/staff");
const today = todayIn(staff.business.timezone);
const summary = await getDashboardSummary(prisma, {
businessId: staff.businessId,
businessTimezone: staff.business.timezone,
date: today,
});
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted text-sm">{staff.business.name} — today</p>
</div>
{summary.warnings.length > 0 && (
<section className="space-y-2">
{summary.warnings.map((w, i) => (
<div
key={`${w.code}-${i}`}
role="alert"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-jumbo border p-4 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{w.message}</p>
</div>
))}
</section>
)}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
</section>
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
<StatTile
icon={TrendingUp}
label="Estimated occupancy"
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
/>
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
</h2>
{summary.staffWorkingToday.length === 0 ? (
<p className="text-muted text-sm">No one is scheduled to work today.</p>
) : (
<ul className="space-y-2">
{summary.staffWorkingToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
</li>
))}
</ul>
)}
{summary.staffOnTimeOffToday.length > 0 && (
<ul className="border-line mt-3 space-y-2 border-t pt-3">
{summary.staffOnTimeOffToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted text-xs font-bold tracking-widest uppercase">
{s.reason ?? "Time off"}
</span>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
</h2>
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
{summary.popularServices.length === 0 ? (
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
) : (
<ul className="space-y-2">
{summary.popularServices.map((s) => (
<li key={s.serviceId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted">{s.count}</span>
</li>
))}
</ul>
)}
</Card>
</section>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
</h2>
{summary.recentLoyaltyActivity.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<ul className="space-y-2">
{summary.recentLoyaltyActivity.map((t) => (
<li key={t.id} className="text-muted text-xs">
<span className="text-ink font-bold">
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
</span>{" "}
— {t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
))}
</ul>
)}
</Card>
<div className="flex flex-wrap gap-3">
<Link
href="/owner/team"
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
>
Manage Team
</Link>
<Link
href="/owner/settings/booking-rules"
className="rounded-jumbo bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
>
Update Booking Rules
</Link>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { listCategories } from "@/services/catalogue/service-catalogue.service";
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
export const metadata: Metadata = { title: "Services" };
export const dynamic = "force-dynamic";
export default async function OwnerServicesPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const categories = await listCategories(prisma, owner.businessId);
return (
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Services</h1>
<CatalogueManager
categories={categories.map((c) => ({
id: c.id,
name: c.name,
isActive: c.isActive,
services: c.services.map((s) => ({
id: s.id,
categoryId: s.categoryId,
name: s.name,
description: s.description ?? "",
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,
bufferAfterMinutes: s.bufferAfterMinutes,
isActive: s.isActive,
allowOnlineBooking: s.allowOnlineBooking,
})),
}))}
/>
</div>
);
}
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
export const metadata: Metadata = { title: "Booking Rules" };
export const dynamic = "force-dynamic";
export default async function OwnerBookingRulesPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const rule = await getBookingRule(prisma, owner.businessId);
if (!rule) redirect("/owner");
return (
<div className="pb-12">
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</main>
</div>
);
}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
export const metadata: Metadata = { title: "Business Details" };
export const dynamic = "force-dynamic";
export default async function OwnerBusinessDetailsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) redirect("/staff");
const business = await getBusinessProfile(prisma, staff.businessId);
if (!business) redirect("/owner");
const location = business.locations[0];
return (
<div className="pb-12">
<TopNav title="Business Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl ?? "",
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
instagramUrl: business.instagramUrl ?? "",
facebookUrl: business.facebookUrl ?? "",
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
addressLine1: location?.addressLine1 ?? "",
addressLine2: location?.addressLine2 ?? "",
city: location?.city ?? "",
region: location?.region ?? "",
postalCode: location?.postalCode ?? "",
country: location?.country ?? "GB",
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</main>
</div>
);
}
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
export const metadata: Metadata = { title: "Opening Hours" };
export const dynamic = "force-dynamic";
export default async function OwnerOpeningHoursPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) redirect("/staff");
const hours = await getOpeningHours(prisma, staff.businessId);
return (
<div className="pb-12">
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<OpeningHoursForm initialRows={hours} />
</main>
</div>
);
}
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
export const metadata: Metadata = { title: "Loyalty Programme" };
export const dynamic = "force-dynamic";
export default async function OwnerLoyaltyConfigPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const [programme, services, staff] = await Promise.all([
getProgrammeConfig(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
]);
const initialConfig = programme
? {
isEnabled: programme.isEnabled,
stampsRequired: programme.stampsRequired,
rewardType: programme.rewardType,
rewardValue: programme.rewardValue?.toString(),
rewardLabel: programme.rewardLabel ?? undefined,
maxRewardValue: programme.maxRewardValue?.toString(),
freeServiceId: programme.freeServiceId ?? undefined,
autoApplyReward: programme.autoApplyReward,
requiresStaffApproval: programme.requiresStaffApproval,
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
earnOnCancelled: programme.earnOnCancelled,
earnOnNoShow: programme.earnOnNoShow,
cardBackgroundColor: programme.cardBackgroundColor ?? undefined,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
stampIcon: programme.stampIcon ?? undefined,
termsLine: programme.termsLine ?? undefined,
}
: {
isEnabled: false,
stampsRequired: 10,
rewardType: "PERCENTAGE" as const,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
};
return (
<div className="pb-12">
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<LoyaltyConfigForm
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</main>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { ProfileCard } from "@/components/ui/profile-card";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
export const metadata: Metadata = { title: "Owner Settings" };
export const dynamic = "force-dynamic";
const SETTINGS_LINKS = [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
] as const;
export default async function OwnerSettingsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<h1 className="text-2xl font-bold tracking-tight">Owner Settings</h1>
<ProfileCard name={session.user.name ?? "Owner"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
<Link
href="/staff/settings"
className="rounded-jumbo border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
>
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
Switch to Barber Tools
</Link>
<ul className="divide-line rounded-jumbo border-line divide-y border bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
{label}
</Link>
</li>
))}
</ul>
<SignOutButton />
</div>
);
}
@@ -0,0 +1,76 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission, Weekday } from "@/generated/prisma/enums";
import { getWorkingHours } from "@/services/staff/staff.service";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
export const metadata: Metadata = { title: "Staff Working Hours" };
export const dynamic = "force-dynamic";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function formatMinutes(m: number): string {
const h = Math.floor(m / 60);
const min = m % 60;
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
}
export default async function OwnerStaffWorkingHoursPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const [workingHours, businessHours] = await Promise.all([
getWorkingHours(prisma, id),
prisma.businessHours.findMany({ where: { businessId: owner.businessId } }),
]);
const businessHoursLabel = Object.fromEntries(
DAY_ORDER.map((day) => {
const hours = businessHours.find((h) => h.dayOfWeek === day);
const label =
!hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null
? "Closed"
: `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
return [day, label];
}),
) as Record<Weekday, string>;
return (
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<WorkingHoursForm
initialRows={workingHours}
businessHoursLabel={businessHoursLabel}
onSave={(days) => updateStaffWorkingHoursAction(id, days)}
/>
</main>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import { Mail, Phone } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
export const metadata: Metadata = { title: "Staff Details" };
export const dynamic = "force-dynamic";
export default async function OwnerStaffDetailPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const { id } = await params;
const [target, roles, allServices] = await Promise.all([
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
listRolesForBusiness(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
]);
if (!target) notFound();
return (
<div className="pb-12">
<TopNav title="Staff Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6 lg:max-w-4xl lg:grid lg:max-w-none lg:grid-cols-[320px_1fr] lg:items-start lg:gap-6 lg:space-y-0">
<div className="space-y-4">
<Card className="flex flex-col items-center p-6 text-center">
{target.user.image ? (
// eslint-disable-next-line @next/next/no-img-element -- external avatar URL, no next/image domain config for it
<img src={target.user.image} alt="" className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover" />
) : (
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
{(target.user.name ?? "?").charAt(0)}
</div>
)}
<h2 className="text-xl font-bold">{target.user.name}</h2>
<p className="text-accent mt-1 text-xs font-bold tracking-widest uppercase">
{target.role.name} · {target.status === "ACTIVE" ? "Active" : target.status === "ON_BREAK" ? "On Break" : "Inactive"}
</p>
<p className="text-muted mt-2 text-xs">Joined {target.createdAt.toLocaleDateString("en-GB", { month: "short", year: "numeric" })}</p>
{target.bio && <p className="text-muted mt-3 text-sm">{target.bio}</p>}
</Card>
<div className="grid grid-cols-2 gap-3">
{target.user.phone && (
<a href={`tel:${target.user.phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
</a>
)}
{target.user.email && (
<a href={`mailto:${target.user.email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
</a>
)}
</div>
</div>
<StaffDetailPanel
staffId={target.id}
currentRoleId={target.roleId}
roles={roles.map((r) => ({ id: r.id, name: r.name }))}
allServices={allServices.map((s) => ({ id: s.id, name: s.name }))}
initialServiceIds={target.services.map((s) => s.serviceId)}
status={target.status}
hasPendingInvitation={!!target.invitation && !target.invitation.acceptedAt}
/>
</main>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { listRolesForBusiness } from "@/services/staff/staff.service";
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
export const metadata: Metadata = { title: "Add Staff" };
export const dynamic = "force-dynamic";
export default async function OwnerAddStaffPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const [roles, services] = await Promise.all([
listRolesForBusiness(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
]);
return (
<div className="pb-12">
<TopNav title="Add Staff" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<AddStaffForm roles={roles.map((r) => ({ id: r.id, name: r.name }))} services={services.map((s) => ({ id: s.id, name: s.name }))} />
</main>
</div>
);
}
+91 -5
View File
@@ -1,9 +1,95 @@
import type { Metadata } from "next";
import { Users } from "lucide-react";
import { ComingSoon } from "@/components/feedback/coming-soon";
import Link from "next/link";
import { Plus, Search } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { listStaffForBusiness } from "@/services/staff/staff.service";
import { cn } from "@/lib/utils/cn";
export const metadata: Metadata = { title: "Team Management" };
export const metadata: Metadata = { title: "Team" };
export const dynamic = "force-dynamic";
export default function OwnerTeamPage() {
return <ComingSoon icon={Users} title="Team Management" phase="Phase 4: Owner Administration" />;
export default async function OwnerTeamPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const { q = "" } = await searchParams;
const staff = await listStaffForBusiness(prisma, { businessId: owner.businessId, q, includeInactive: true });
return (
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Team</h1>
<Link
href="/owner/team/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add staff member"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<form action="/owner/team" method="GET" className="relative mb-4">
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
<label htmlFor="team-search" className="sr-only">
Search staff by name or email
</label>
<input
id="team-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name or email"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</form>
<div className="grid gap-3 lg:grid-cols-2">
{staff.length === 0 ? (
<p className="text-muted col-span-full rounded-jumbo border border-line bg-white p-5 text-center text-sm">
No staff match &ldquo;{q}&rdquo;.
</p>
) : (
staff.map((s) => (
<Link
key={s.id}
href={`/owner/team/${s.id}`}
className={cn(
"rounded-jumbo border-line flex items-center gap-4 border bg-white p-5",
s.status === "INACTIVE" && "opacity-60",
)}
>
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{(s.user.name ?? "?").charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">{s.user.name}</p>
<p className="text-muted truncate text-xs">{s.user.email}</p>
<p className="text-accent mt-0.5 text-[10px] font-bold tracking-widest uppercase">
{s.role.name}
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
<span className="text-warning-foreground"> · Invite Pending</span>
)}
</p>
</div>
</Link>
))
)}
</div>
</div>
);
}
@@ -13,6 +13,7 @@ import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/sta
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
@@ -74,6 +75,9 @@ export default async function StaffAppointmentDetailsPage({
0,
);
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
const canRedeemReward =
loyalty?.availableReward && appointment.discountAmount === null && appointment.status !== "CANCELLED";
return (
<div className="pb-12">
@@ -163,6 +167,9 @@ export default async function StaffAppointmentDetailsPage({
</span>
<p className="font-bold">
{totalDuration} min · £{totalPrice.toFixed(2)}
{discount > 0 && (
<span className="text-accent-loyalty"> (-£{discount.toFixed(2)} reward)</span>
)}
</p>
</div>
</div>
@@ -257,9 +264,19 @@ export default async function StaffAppointmentDetailsPage({
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
{loyalty?.isEnabled && (
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
)}
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && (
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
)}
{canRedeemReward && loyalty?.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={totalPrice}
/>
)}
</div>
</main>
</div>
);
@@ -0,0 +1,10 @@
/**
* Deliberately outside the (owner) route group, same reasoning as
* src/app/staff/appointments/[id]/reschedule/layout.tsx: this page reuses
* BarberDateTimePicker, whose own fixed bottom action bar would otherwise
* sit under the persistent owner sidebar/bottom-nav and swallow clicks
* meant for "Confirm Reschedule".
*/
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
}
@@ -0,0 +1,111 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
export const metadata: Metadata = { title: "Reschedule / Reassign" };
export const dynamic = "force-dynamic";
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
/**
* Also the owner's appointment-reassignment surface — BarberDateTimePicker
* already lets the caller choose a different eligible barber as part of
* picking a new slot, so "reassign to another barber" and "reschedule to a
* new time" are the same underlying operation here, not two separate
* flows. See docs/phase-6-summary.md, "Appointment reassignment approach".
*/
export default async function OwnerRescheduleAppointmentPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, timezone: true } } },
});
if (!owner) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
if (NON_RESCHEDULABLE.has(appointment.status)) {
redirect(`/owner/appointments/${id}`);
}
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
const result = await ownerRescheduleAppointmentAction({
appointmentId: id,
staffId: selection.staffId,
startAt: selection.startAt,
});
if (result.success) {
redirect(`/owner/appointments/${id}`);
}
return result;
}
return (
<div className="pb-6">
<TopNav title="Reschedule / Reassign" onBack sticky className="bg-stone" />
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
<div className="flex-1">
<p className="text-sm font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")} for {appointment.customer.firstName}{" "}
{appointment.customer.lastName}
</p>
<p className="text-muted text-xs">
Currently with {appointment.staff.user.name} —{" "}
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: owner.business.timezone,
})}{" "}
·{" "}
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: owner.business.timezone,
})}
</p>
</div>
</Card>
<div className="mt-4">
<BarberDateTimePicker
businessId={owner.businessId}
businessTimezone={owner.business.timezone}
serviceIds={appointment.services.map((s) => s.serviceId)}
initialStaffId={appointment.staffId}
onContinue={handleContinue}
continueLabel="Confirm"
/>
</div>
</div>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CalendarCheck, CalendarDays, House, Scissors, Settings, User, Users } from "lucide-react";
import { CalendarCheck, CalendarDays, ChartLine, House, Scissors, Settings, User, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn";
@@ -23,6 +23,7 @@ export const NAV_ICON_MAP = {
scissors: Scissors,
users: Users,
settings: Settings,
"chart-line": ChartLine,
} satisfies Record<string, LucideIcon>;
export type BottomNavIconName = keyof typeof NAV_ICON_MAP;
+19 -4
View File
@@ -1,14 +1,29 @@
import { BottomNav } from "./bottom-nav";
import { StaffSidebarNav } from "./staff-sidebar-nav";
import type { BottomNavItem } from "./bottom-nav";
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
/** Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner is a permission on a staff account, not a separate shell. */
export function StaffShell({ children }: { children: React.ReactNode }) {
/**
* Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner
* is a permission on a staff account, not a separate shell. `items`
* defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see
* staff-nav-items.ts) — same shell, same sidebar/bottom-nav components,
* different item set, per the Phase 6 brief's own nav requirement.
*/
export function StaffShell({
children,
items = STAFF_NAV_ITEMS,
footerLink,
}: {
children: React.ReactNode;
items?: BottomNavItem[];
footerLink?: { href: string; label: string };
}) {
return (
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
<StaffSidebarNav items={STAFF_NAV_ITEMS} />
<StaffSidebarNav items={items} footerLink={footerLink} />
{children}
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
<BottomNav items={items} variant="staff" />
</div>
);
}
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Scissors } from "lucide-react";
import { ArrowLeftRight, Scissors } from "lucide-react";
import { cn } from "@/lib/utils/cn";
import { NAV_ICON_MAP } from "./bottom-nav";
import type { BottomNavItem } from "./bottom-nav";
@@ -15,7 +15,14 @@ import type { BottomNavItem } from "./bottom-nav";
* to spare, per the brief's explicit call-out that staff desktop needs
* more than the mobile bar simply centered.
*/
export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
export function StaffSidebarNav({
items,
footerLink,
}: {
items: BottomNavItem[];
/** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */
footerLink?: { href: string; label: string };
}) {
const pathname = usePathname();
return (
@@ -27,7 +34,7 @@ export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
<Scissors className="h-5 w-5" aria-hidden="true" />
<span className="text-sm font-bold tracking-widest uppercase">NextChair</span>
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-1 flex-col gap-1">
{items.map(({ href, label, icon }) => {
const Icon = NAV_ICON_MAP[icon];
const active = pathname === href || pathname.startsWith(`${href}/`);
@@ -47,6 +54,15 @@ export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
);
})}
</div>
{footerLink && (
<Link
href={footerLink.href}
className="mt-4 flex items-center gap-3 rounded-xl border-t border-white/5 px-3 pt-4 text-sm font-bold text-gray-400 transition-colors hover:text-white"
>
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
{footerLink.label}
</Link>
)}
</nav>
);
}
@@ -0,0 +1,47 @@
"use server";
import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
import { acceptInvitationSchema } from "@/features/auth/schemas";
export interface AcceptInvitationResult {
success: boolean;
error?: string;
fieldErrors?: Record<string, string[] | undefined>;
}
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> {
const parsed = acceptInvitationSchema.safeParse({
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
});
if (!parsed.success) {
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const check = await validateInvitationToken(prisma, token);
if (!check.valid) {
const message =
check.reason === "EXPIRED"
? "This invitation link has expired. Ask the business owner to send you a new one."
: check.reason === "ALREADY_ACCEPTED"
? "This invitation has already been used — try signing in instead."
: "This invitation link isn't valid.";
return { success: false, error: message };
}
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
if (!result.success) {
return { success: false, error: "Couldn't accept this invitation. Please try again." };
}
await signIn("credentials", {
email: check.invitation.staff.user.email,
password: parsed.data.password,
redirectTo: "/staff",
});
return { success: true };
}
@@ -0,0 +1,69 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation";
import { acceptInvitationSchema } from "@/features/auth/schemas";
import type { AcceptInvitationInput } from "@/features/auth/schemas";
export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
const onSubmit = async (data: AcceptInvitationInput) => {
setFormError(null);
const formData = new FormData();
formData.set("password", data.password);
formData.set("confirmPassword", data.confirmPassword);
const result = await acceptInvitationAction(token, formData);
if (!result.success) {
setFormError(result.error ?? "Something went wrong. Please try again.");
if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) setError(field as keyof AcceptInvitationInput, { message: messages[0] });
}
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<p className="text-muted text-sm">
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
a password to finish setting up your account.
</p>
<Input
label="Password"
type="password"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
<Input
label="Confirm Password"
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
{formError && (
<p role="alert" className="text-danger text-sm font-medium">
{formError}
</p>
)}
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Set Password &amp; Sign In
</Button>
</form>
);
}
+11
View File
@@ -26,3 +26,14 @@ export const registerSchema = z
path: ["confirmPassword"],
});
export type RegisterInput = z.infer<typeof registerSchema>;
export const acceptInvitationSchema = z
.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasPermission } from "@/lib/auth/permissions";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "../authorization";
import { deactivateStaff, updateStaffAsOwner } from "@/services/staff/staff.service";
import { deleteCategory } from "@/services/catalogue/service-catalogue.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Real-DB coverage of "a normal barber must not be able to perform owner
* actions" and "owner actions never leak across businesses" — the two
* defense-in-depth rules the Phase 6 brief calls out repeatedly. This
* re-derives permissions from the database exactly as the owner Server
* Actions do (assertOwnerPermission -> hasPermission -> a fresh
* StaffProfile/Role/RolePermission query), rather than trusting a cached
* session claim.
*/
describe("Owner authorization and business isolation (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("hasPermission / assertOwnerPermission", () => {
it("grants MANAGE_STAFF to a staff member whose role holds it", async () => {
const ownerRole = await prisma.role.create({
data: {
businessId: business.businessId,
name: "Owner",
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
},
});
const { userId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { userId }, data: { roleId: ownerRole.id } });
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(true);
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).resolves.toBeUndefined();
});
it("denies MANAGE_STAFF to an ordinary barber whose role doesn't hold it", async () => {
const { userId } = await createTestStaff(business); // createTestStaff's auto-role has no permissions
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(false);
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).rejects.toBeInstanceOf(OwnerPermissionDeniedError);
});
it("reflects a permission change immediately — no caching across the role edit", async () => {
const barberRole = await prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
const { userId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { userId }, data: { roleId: barberRole.id } });
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(false);
await prisma.rolePermission.create({ data: { roleId: barberRole.id, permission: "MANAGE_SERVICES" } });
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(true);
});
});
describe("business isolation", () => {
it("updateStaffAsOwner can't reach a staff member from a different business", async () => {
const otherBusiness = await createTestBusiness();
try {
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
const result = await updateStaffAsOwner(prisma, {
staffId: otherStaffId,
businessId: business.businessId,
input: { bio: "hijacked" },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("STAFF_NOT_FOUND");
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
expect(unaffected.bio).not.toBe("hijacked");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deactivateStaff can't reach a staff member from a different business", async () => {
const otherBusiness = await createTestBusiness();
try {
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
const result = await deactivateStaff(prisma, { staffId: otherStaffId, businessId: business.businessId });
expect(result.success).toBe(false);
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
expect(unaffected.status).toBe("ACTIVE");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deleteCategory can't reach a category from a different business", async () => {
const otherBusiness = await createTestBusiness();
try {
// Move the other business's fixture category to be empty so only
// business-scoping (not the has-services guard) is under test.
await prisma.service.deleteMany({ where: { businessId: otherBusiness.businessId } });
const result = await deleteCategory(prisma, {
categoryId: otherBusiness.categoryId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: otherBusiness.categoryId } });
expect(stillExists).not.toBeNull();
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
});
@@ -0,0 +1,24 @@
import type { AppointmentActionSet } from "@/features/staff/components/appointment-status-actions";
import {
ownerCancelAppointmentAction,
ownerCheckInAppointmentAction,
ownerCompleteAppointmentAction,
ownerConfirmAppointmentAction,
ownerMarkNoShowAction,
ownerStartAppointmentAction,
} from "./appointments";
/**
* A plain object assembling the owner Server Actions into the shape
* AppointmentStatusActions expects — kept in its own (non-"use server")
* module because a "use server" file may only export async functions, not
* an object literal, even one whose values are all Server Actions.
*/
export const ownerAppointmentActions: AppointmentActionSet = {
confirm: ownerConfirmAppointmentAction,
checkIn: ownerCheckInAppointmentAction,
start: ownerStartAppointmentAction,
complete: ownerCompleteAppointmentAction,
markNoShow: ownerMarkNoShowAction,
cancel: ownerCancelAppointmentAction,
};
+178
View File
@@ -0,0 +1,178 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import {
checkIn,
complete,
confirm,
getAppointmentById,
markNoShow,
start,
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
/**
* Owner equivalents of src/features/staff/actions/appointment-actions.ts —
* same AppointmentService/BookingService/LoyaltyService calls, but scoped
* to "any appointment in the owner's own business" (assertAppointmentInBusiness)
* behind a MANAGE_ALL_APPOINTMENTS permission check, instead of "my own
* appointments only". Barber permissions are untouched; this is a
* parallel, explicit path, not a weakening of assertOwnAppointment — see
* docs/phase-6-summary.md, "Permission and business-isolation rules".
*/
async function loadOwnerAppointment(appointmentId: string) {
const session = await auth();
if (!session?.user) {
return { owner: null, appointment: null, error: "You must be signed in." } as const;
}
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) {
return { owner: null, appointment: null, error: error.message } as const;
}
throw error;
}
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) return { owner: null, appointment: null, error: "You must be signed in as staff." } as const;
const appointment = await getAppointmentById(prisma, appointmentId);
if (!appointment) return { owner, appointment: null, error: "Appointment not found." } as const;
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) {
return { owner, appointment: null, error: error.message } as const;
}
throw error;
}
return { owner, appointment, error: null } as const;
}
function friendlyTransitionMessage(code?: string): string {
if (code === "INVALID_STATUS_TRANSITION") {
return "This appointment can't move to that status right now — someone may have already updated it.";
}
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
return "Something went wrong. Please try again.";
}
export async function ownerConfirmAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await confirm(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await checkIn(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerStartAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await start(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerCompleteAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { owner, appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
const result = await complete(prisma, appointmentId);
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
}
export async function ownerMarkNoShowAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await markNoShow(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerCancelAppointmentAction(params: {
appointmentId: string;
reason?: string;
}): Promise<StaffAppointmentActionResult> {
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
const result = await cancelBooking(prisma, {
appointmentId: params.appointmentId,
cancelledByUserId: owner.userId,
reason: params.reason,
});
return result.success
? { success: true }
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
}
export interface OwnerRescheduleResult {
success: boolean;
error?: string;
}
/** Reassignment is just a reschedule with a different staffId and the same slot — see docs/phase-6-summary.md, "Appointment reassignment approach". Also covers a genuine date/time reschedule when startAt changes. */
export async function ownerRescheduleAppointmentAction(params: {
appointmentId: string;
staffId?: string;
startAt: string;
}): Promise<OwnerRescheduleResult> {
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
const previousStaffId = appointment.staffId;
const result = await rescheduleBooking(prisma, {
appointmentId: params.appointmentId,
staffId: params.staffId,
startAt: new Date(params.startAt),
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
}
if (params.staffId && params.staffId !== previousStaffId) {
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "APPOINTMENT_REASSIGNED",
entityType: "Appointment",
entityId: params.appointmentId,
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
});
}
return { success: true };
}
@@ -0,0 +1,43 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { updateBookingRule } from "@/services/scheduling/booking-rule.service";
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
export interface BookingRuleActionResult {
success: boolean;
error?: string;
}
export async function updateBookingRuleAction(input: BookingRuleInput): Promise<BookingRuleActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
throw error;
}
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) return { success: false, error: "You must be signed in as staff." };
const result = await updateBookingRule(prisma, { businessId: owner.businessId, input });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save booking rules." };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "BOOKING_RULES_UPDATED",
entityType: "BookingRule",
entityId: owner.businessId,
});
return { success: true };
}
+121
View File
@@ -0,0 +1,121 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import {
setOpeningHours,
updateBusinessProfile,
updateBusinessSettings,
} from "@/services/business/business.service";
import type { BusinessLocationInput, BusinessProfileInput, BusinessSettingsInput, OpeningHoursDayInput } from "@/services/business/business.service";
export interface BusinessActionResult {
success: boolean;
error?: string;
}
async function requireBusinessManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export async function updateBusinessProfileAction(params: {
profile: BusinessProfileInput;
location: BusinessLocationInput;
}): Promise<BusinessActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const result = await updateBusinessProfile(prisma, { businessId: staff.businessId, ...params });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save business details." };
}
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_PROFILE_UPDATED",
entityType: "Business",
entityId: staff.businessId,
metadata: { name: params.profile.name },
});
return { success: true };
}
export async function updateBusinessSettingsAction(settings: BusinessSettingsInput): Promise<BusinessActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_SETTINGS_UPDATED",
entityType: "BusinessSettings",
entityId: staff.businessId,
});
return { success: true };
}
export interface OpeningHoursActionResult {
success: boolean;
error?: string;
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string; staffName: string }[];
}
export async function updateOpeningHoursAction(
days: OpeningHoursDayInput[],
acknowledgeConflicts?: boolean,
): Promise<OpeningHoursActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const business = await prisma.business.findUnique({ where: { id: staff.businessId }, select: { timezone: true } });
if (!business) return { success: false, error: "Business not found." };
const result = await setOpeningHours(prisma, {
businessId: staff.businessId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't save opening hours.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
staffName: c.staff.user.name ?? "Staff",
})),
};
}
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_HOURS_UPDATED",
entityType: "BusinessHours",
entityId: staff.businessId,
});
return { success: true };
}
+79
View File
@@ -0,0 +1,79 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { createTimeOff } from "@/services/staff/staff.service";
export interface OwnerBlockTimeConflict {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface OwnerBlockTimeResult {
success: boolean;
error?: string;
conflicts?: OwnerBlockTimeConflict[];
}
/** Owner variant of Phase 5's blockTimeAction — same underlying StaffService.createTimeOff, just targeting any staff member in the business rather than only the caller's own schedule. See docs/phase-5-summary.md for why Block Time is a partial-day TimeOff row, not a separate model. */
export async function ownerBlockTimeAction(input: {
staffId: string;
date: string;
startMinutes: number;
endMinutes: number;
reason: string;
acknowledgeConflicts?: boolean;
}): Promise<OwnerBlockTimeResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
throw error;
}
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { timezone: true } } },
});
if (!owner) return { success: false, error: "You must be signed in as staff." };
const target = await prisma.staffProfile.findFirst({
where: { id: input.staffId, businessId: owner.businessId },
});
if (!target) return { success: false, error: "Staff member not found." };
const result = await createTimeOff(prisma, {
staffId: input.staffId,
businessTimezone: owner.business.timezone,
input: {
startDate: input.date,
endDate: input.date,
reason: input.reason.trim() || "Blocked",
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
},
acknowledgeConflicts: input.acknowledgeConflicts,
});
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
conflicts: result.conflicts?.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
return { success: true };
}
+144
View File
@@ -0,0 +1,144 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import {
createCategory,
createService,
deleteCategory,
reorderCategories,
reorderServices,
setCategoryActive,
setServiceActive,
updateCategory,
updateService,
} from "@/services/catalogue/service-catalogue.service";
import type { CategoryInput, ServiceInput } from "@/services/catalogue/service-catalogue.service";
export interface CatalogueActionResult {
success: boolean;
error?: string;
}
async function requireServiceManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_SERVICES);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export async function createCategoryAction(input: CategoryInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await createCategory(prisma, { businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create category." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_CREATED", entityType: "ServiceCategory", entityId: result.category.id });
return { success: true };
}
export async function updateCategoryAction(categoryId: string, input: CategoryInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateCategory(prisma, { categoryId, businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update category." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_UPDATED", entityType: "ServiceCategory", entityId: categoryId });
return { success: true };
}
export async function setCategoryActiveAction(categoryId: string, isActive: boolean): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await setCategoryActive(prisma, { categoryId, businessId: owner.businessId, isActive });
if (!result.success) return { success: false, error: "Category not found." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: isActive ? "CATEGORY_ACTIVATED" : "CATEGORY_DEACTIVATED",
entityType: "ServiceCategory",
entityId: categoryId,
});
return { success: true };
}
export async function deleteCategoryAction(categoryId: string): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await deleteCategory(prisma, { categoryId, businessId: owner.businessId });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't delete category." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_DELETED", entityType: "ServiceCategory", entityId: categoryId });
return { success: true };
}
export async function reorderCategoriesAction(orderedCategoryIds: string[]): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
await reorderCategories(prisma, { businessId: owner.businessId, orderedCategoryIds });
return { success: true };
}
export async function createServiceAction(input: ServiceInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await createService(prisma, { businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create service." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_CREATED", entityType: "Service", entityId: result.service.id, metadata: { name: input.name } });
return { success: true };
}
export async function updateServiceAction(serviceId: string, input: ServiceInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateService(prisma, { serviceId, businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update service." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_UPDATED", entityType: "Service", entityId: serviceId });
return { success: true };
}
export async function setServiceActiveAction(serviceId: string, isActive: boolean): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await setServiceActive(prisma, { serviceId, businessId: owner.businessId, isActive });
if (!result.success) return { success: false, error: "Service not found." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: isActive ? "SERVICE_ACTIVATED" : "SERVICE_DEACTIVATED",
entityType: "Service",
entityId: serviceId,
});
return { success: true };
}
export async function reorderServicesAction(categoryId: string, orderedServiceIds: string[]): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
await reorderServices(prisma, { businessId: owner.businessId, categoryId, orderedServiceIds });
return { success: true };
}
@@ -0,0 +1,61 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { setEligibleServices, setEligibleStaff, updateProgrammeConfig } from "@/services/loyalty/loyalty.service";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
export interface LoyaltyConfigActionResult {
success: boolean;
error?: string;
}
async function requireLoyaltyManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export async function updateLoyaltyConfigAction(params: {
config: ProgrammeConfigInput;
eligibleServiceIds: string[];
eligibleStaffIds: string[];
}): Promise<LoyaltyConfigActionResult> {
const { staff: owner, error } = await requireLoyaltyManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateProgrammeConfig(prisma, { businessId: owner.businessId, input: params.config });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save loyalty settings." };
}
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: owner.businessId } });
await Promise.all([
setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: params.eligibleServiceIds }),
setEligibleStaff(prisma, { loyaltyProgrammeId: programme.id, staffIds: params.eligibleStaffIds }),
]);
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "LOYALTY_CONFIG_UPDATED",
entityType: "LoyaltyProgramme",
entityId: programme.id,
metadata: { isEnabled: params.config.isEnabled, rewardType: params.config.rewardType },
});
return { success: true };
}
+58
View File
@@ -0,0 +1,58 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { hasPermission } from "@/lib/auth/permissions";
import { redeemReward } from "@/services/loyalty/loyalty.service";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
export interface RedeemRewardResult {
success: boolean;
error?: string;
discountAmount?: string;
}
/**
* Redemption is an operational action a permitted barber does day to day
* (not "configure the loyalty programme"), so it's gated on
* MANAGE_LOYALTY_STAMPS — the same permission Phase 5 already grants to
* the Barber role for adding/correcting stamps — rather than
* MANAGE_LOYALTY_PROGRAMME, which is owner-only configuration. Both Owner
* and Barber roles hold MANAGE_LOYALTY_STAMPS in the seed. See
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
*/
export async function redeemRewardAction(params: {
rewardId: string;
appointmentId: string;
}): Promise<RedeemRewardResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
const allowed = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_STAMPS);
if (!allowed) return { success: false, error: "You don't have permission to do that." };
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { success: false, error: "You must be signed in as staff." };
const result = await redeemReward(prisma, {
rewardId: params.rewardId,
appointmentId: params.appointmentId,
staffId: staff.id,
businessId: staff.businessId,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't redeem this reward." };
}
await writeAuditLog(prisma, {
actorUserId: session.user.id,
action: "LOYALTY_REWARD_REDEEMED",
entityType: "LoyaltyReward",
entityId: params.rewardId,
metadata: { appointmentId: params.appointmentId, discountAmount: result.discountAmount },
});
return { success: true, discountAmount: result.discountAmount };
}
+230
View File
@@ -0,0 +1,230 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import {
createStaffProfile,
deactivateStaff,
reactivateStaff,
setStaffServiceEligibility,
setWorkingHours,
updateStaffAsOwner,
} from "@/services/staff/staff.service";
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
async function requireStaffManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export interface AddStaffResult {
success: boolean;
error?: string;
staffId?: string;
inviteToken?: string;
}
export async function addStaffAction(input: CreateStaffInput): Promise<AddStaffResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await createStaffProfile(prisma, { businessId: owner.businessId, input });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this staff member." };
}
const invitation = await createInvitation(prisma, {
businessId: owner.businessId,
staffId: result.staff.id,
email: input.email.toLowerCase(),
invitedByUserId: owner.userId,
});
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_ADDED",
entityType: "StaffProfile",
entityId: result.staff.id,
metadata: { name: input.name, email: input.email, roleId: input.roleId },
});
return { success: true, staffId: result.staff.id, inviteToken: invitation.token };
}
export interface StaffActionResult {
success: boolean;
error?: string;
}
export async function updateStaffAction(staffId: string, input: UpdateStaffAsOwnerInput): Promise<StaffActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateStaffAsOwner(prisma, { staffId, businessId: owner.businessId, input });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this staff member." };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: input.roleId ? "STAFF_ROLE_CHANGED" : "STAFF_UPDATED",
entityType: "StaffProfile",
entityId: staffId,
metadata: { ...(input.roleId ? { roleId: input.roleId } : {}) },
});
return { success: true };
}
export interface DeactivateStaffResult {
success: boolean;
error?: string;
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
}
export async function deactivateStaffAction(staffId: string, acknowledgeConflicts?: boolean): Promise<DeactivateStaffResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await deactivateStaff(prisma, { staffId, businessId: owner.businessId, acknowledgeConflicts });
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't deactivate this staff member.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_DEACTIVATED",
entityType: "StaffProfile",
entityId: staffId,
});
return { success: true };
}
export async function reactivateStaffAction(staffId: string): Promise<StaffActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await reactivateStaff(prisma, { staffId, businessId: owner.businessId });
if (!result.success) return { success: false, error: "Couldn't reactivate this staff member." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_REACTIVATED",
entityType: "StaffProfile",
entityId: staffId,
});
return { success: true };
}
export interface SetEligibilityResult {
success: boolean;
error?: string;
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
}
export async function setStaffServiceEligibilityAction(
staffId: string,
serviceIds: string[],
acknowledgeConflicts?: boolean,
): Promise<SetEligibilityResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await setStaffServiceEligibility(prisma, { staffId, businessId: owner.businessId, serviceIds, acknowledgeConflicts });
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't update this staff member's services.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_SERVICES_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
metadata: { serviceIds },
});
return { success: true };
}
export interface WorkingHoursActionResult {
success: boolean;
error?: string;
}
/** Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours call the barber's own Phase 5 editor uses, just targeting a chosen staffId instead of the caller. No conflict detection here, matching the existing self-service editor's behaviour (a documented gap — see docs/phase-6-summary.md). */
export async function updateStaffWorkingHoursAction(
staffId: string,
days: WorkingHoursDayInput[],
): Promise<WorkingHoursActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
if (!target) return { success: false, error: "Staff member not found." };
const result = await setWorkingHours(prisma, { staffId, days });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_WORKING_HOURS_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
return { success: true };
}
export interface RegenerateInvitationResult {
success: boolean;
error?: string;
inviteToken?: string;
}
export async function regenerateInvitationAction(staffId: string): Promise<RegenerateInvitationResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const invitation = await regenerateInvitation(prisma, { staffId, businessId: owner.businessId });
if (!invitation) return { success: false, error: "No invitation found for this staff member." };
return { success: true, inviteToken: invitation.token };
}
+25
View File
@@ -0,0 +1,25 @@
import type { Permission } from "@/generated/prisma/enums";
import { hasPermission } from "@/lib/auth/permissions";
/**
* Per-action owner permission checks — the mirror of
* src/features/staff/authorization.ts and src/features/booking/authorization.ts.
* The `(owner)` route group layout already gates on MANAGE_STAFF as a
* coarse "is this person any kind of owner" shell check, but that is not
* enough on its own (see docs/phase-6-summary.md, "Permission and
* business-isolation rules") — every owner Server Action re-derives the
* session and checks the *specific* permission that action needs, since a
* future non-Owner role could plausibly hold some but not all of these.
*/
export class OwnerPermissionDeniedError extends Error {
constructor(message = "You don't have permission to do that.") {
super(message);
this.name = "OwnerPermissionDeniedError";
}
}
export async function assertOwnerPermission(userId: string, permission: Permission): Promise<void> {
const allowed = await hasPermission(userId, permission);
if (!allowed) throw new OwnerPermissionDeniedError();
}
@@ -0,0 +1,117 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { addStaffAction } from "../actions/staff";
export interface AddStaffFormProps {
roles: { id: string; name: string }[];
services: { id: string; name: string }[];
}
export function AddStaffForm({ roles, services }: AddStaffFormProps) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [image, setImage] = useState("");
const [bio, setBio] = useState("");
const [roleId, setRoleId] = useState(roles[0]?.id ?? "");
const [serviceIds, setServiceIds] = useState<string[]>([]);
const [isBookableOnline, setIsBookableOnline] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isPending, startTransition] = useTransition();
const toggleService = (id: string) => {
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const submit = () => {
setError(null);
startTransition(async () => {
const result = await addStaffAction({ name, email, phone: phone || undefined, image: image || undefined, bio: bio || undefined, roleId, serviceIds, isBookableOnline });
if (!result.success) {
setError(result.error ?? "Couldn't add this staff member.");
return;
}
if (result.inviteToken) {
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
} else {
router.push(`/owner/team/${result.staffId}`);
}
});
};
const copyLink = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
};
if (inviteLink) {
return (
<div className="space-y-4 rounded-jumbo border-line border bg-white p-6">
<p className="text-sm font-bold">{name} has been added.</p>
<p className="text-muted text-sm">
Email delivery isn&apos;t wired up yet — copy this invite link and send it to them yourself. It lets them
set their own password. Expires in 7 days.
</p>
<div className="border-line bg-stone break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={copyLink}>
{copied ? "Copied" : "Copy Link"}
</Button>
<Button className="flex-1" onClick={() => router.push("/owner/team")}>
Done
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
<Input label="Photo URL (optional)" value={image} onChange={(e) => setImage(e.target.value)} />
<Textarea label="Bio (optional)" value={bio} onChange={(e) => setBio(e.target.value)} />
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
</div>
<div className="rounded-jumbo border-line border bg-white p-6">
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
<div className="space-y-3">
{services.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
</div>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
Add Staff Member
</Button>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { updateBookingRuleAction } from "../actions/booking-rules";
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof BookingRuleInput>(key: K, value: BookingRuleInput[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await updateBookingRuleAction(form);
if (!result.success) {
setError(result.error ?? "Couldn't save booking rules.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input
label="Minimum notice (minutes)"
hint="How soon before a slot a booking can be made."
type="number"
min={0}
value={form.minNoticeMinutes}
onChange={(e) => set("minNoticeMinutes", Number(e.target.value))}
/>
<Input
label="Maximum advance booking (days)"
hint="How far ahead customers can book."
type="number"
min={1}
value={form.maxAdvanceDays}
onChange={(e) => set("maxAdvanceDays", Number(e.target.value))}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation &amp; Rescheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input
label="Free cancellation window (hours)"
hint="Cancelling later than this may count as a late cancellation."
type="number"
min={0}
value={form.cancellationWindowHours}
onChange={(e) => set("cancellationWindowHours", Number(e.target.value))}
/>
<Input
label="Free reschedule window (hours)"
type="number"
min={0}
value={form.rescheduleWindowHours}
onChange={(e) => set("rescheduleWindowHours", Number(e.target.value))}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select
label="Appointment slot interval"
value={String(form.slotIntervalMinutes)}
onChange={(e) => set("slotIntervalMinutes", Number(e.target.value))}
>
{[15, 30, 45, 60].map((m) => (
<option key={m} value={m}>
{m} min
</option>
))}
</Select>
<div className="grid grid-cols-2 gap-4">
<Input
label="Buffer before (min)"
type="number"
min={0}
value={form.bufferBeforeMinutes}
onChange={(e) => set("bufferBeforeMinutes", Number(e.target.value))}
/>
<Input
label="Buffer after (min)"
type="number"
min={0}
value={form.bufferAfterMinutes}
onChange={(e) => set("bufferAfterMinutes", Number(e.target.value))}
/>
</div>
<Input
label="Max appointments per day (blank = no limit)"
type="number"
min={1}
value={form.maxAppointmentsPerDay ?? ""}
onChange={(e) => set("maxAppointmentsPerDay", e.target.value ? Number(e.target.value) : null)}
/>
<Input
label="Max appointments per staff member per day (blank = no limit)"
type="number"
min={1}
value={form.maxAppointmentsPerStaffPerDay ?? ""}
onChange={(e) => set("maxAppointmentsPerStaffPerDay", e.target.value ? Number(e.target.value) : null)}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Switch
label="Customers can choose a barber"
description='If off, "Anyone" is assigned automatically.'
checked={form.customerCanChooseStaff}
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
/>
<Switch
label="Allow guest bookings"
description="Let customers book without an account."
checked={form.allowGuestBooking}
onCheckedChange={(v) => set("allowGuestBooking", v)}
/>
<Switch
label="Require approval for new bookings"
description="Bookings wait for staff confirmation before they're CONFIRMED."
checked={form.requireApproval}
onCheckedChange={(v) => set("requireApproval", v)}
/>
<Switch label="Require deposit" description="Coming soon — not enforced yet." checked={false} disabled />
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
const CURRENCIES = ["GBP", "USD", "EUR"];
const LOCALES = ["en-GB", "en-US"];
export interface BusinessDetailsInitial {
name: string;
description: string;
logoUrl: string;
phone: string;
email: string;
website: string;
instagramUrl: string;
facebookUrl: string;
timezone: string;
currency: string;
locale: string;
addressLine1: string;
addressLine2: string;
city: string;
region: string;
postalCode: string;
country: string;
cancellationPolicy: string;
lateArrivalPolicy: string;
bookingTerms: string;
privacyPolicyUrl: string;
}
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof BusinessDetailsInitial>(key: K, value: BusinessDetailsInitial[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const profileResult = await updateBusinessProfileAction({
profile: {
name: form.name,
description: form.description || undefined,
logoUrl: form.logoUrl || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
website: form.website || undefined,
instagramUrl: form.instagramUrl || undefined,
facebookUrl: form.facebookUrl || undefined,
timezone: form.timezone,
currency: form.currency,
locale: form.locale,
},
location: {
addressLine1: form.addressLine1,
addressLine2: form.addressLine2 || undefined,
city: form.city,
region: form.region || undefined,
postalCode: form.postalCode,
country: form.country,
},
});
if (!profileResult.success) {
setError(profileResult.error ?? "Couldn't save business details.");
return;
}
const settingsResult = await updateBusinessSettingsAction({
cancellationPolicy: form.cancellationPolicy || undefined,
lateArrivalPolicy: form.lateArrivalPolicy || undefined,
bookingTerms: form.bookingTerms || undefined,
privacyPolicyUrl: form.privacyPolicyUrl || undefined,
});
if (!settingsResult.success) {
setError(settingsResult.error ?? "Couldn't save business policies.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
<Input label="Logo URL" placeholder="https://…" value={form.logoUrl} onChange={(e) => set("logoUrl", e.target.value)} />
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact &amp; Address</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
</div>
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</Select>
<div className="grid grid-cols-2 gap-4">
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
{LOCALES.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Textarea
label="Cancellation Policy"
value={form.cancellationPolicy}
onChange={(e) => set("cancellationPolicy", e.target.value)}
/>
<Textarea
label="Late Arrival Policy"
value={form.lateArrivalPolicy}
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
/>
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
<Input
label="Privacy Policy URL"
type="url"
placeholder="https://"
value={form.privacyPolicyUrl}
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
/>
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved — changes are live for customers immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,267 @@
"use client";
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
createCategoryAction,
deleteCategoryAction,
reorderCategoriesAction,
reorderServicesAction,
setCategoryActiveAction,
setServiceActiveAction,
updateCategoryAction,
} from "../actions/catalogue";
import { ServiceFormSheet } from "./service-form-sheet";
import type { EditableService } from "./service-form-sheet";
export interface CatalogueService {
id: string;
categoryId: string;
name: string;
description: string;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
isActive: boolean;
allowOnlineBooking: boolean;
}
export interface CatalogueCategory {
id: string;
name: string;
isActive: boolean;
services: CatalogueService[];
}
function formatPrice(price: string): string {
return `£${Number(price).toFixed(2)}`;
}
export function CatalogueManager({ categories }: { categories: CatalogueCategory[] }) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [newCategoryName, setNewCategoryName] = useState("");
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
const [editingCategoryName, setEditingCategoryName] = useState("");
const [sheetOpen, setSheetOpen] = useState(false);
const [sheetCategoryId, setSheetCategoryId] = useState<string>(categories[0]?.id ?? "");
const [editingService, setEditingService] = useState<EditableService | null>(null);
const [isPending, startTransition] = useTransition();
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
setError(null);
startTransition(async () => {
const result = await fn();
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
router.refresh();
});
};
const addCategory = () => {
if (!newCategoryName.trim()) return;
run(async () => {
const result = await createCategoryAction({ name: newCategoryName.trim() });
if (result.success) setNewCategoryName("");
return result;
});
};
const saveCategoryName = (id: string) => {
if (!editingCategoryName.trim()) return;
run(async () => {
const result = await updateCategoryAction(id, { name: editingCategoryName.trim() });
if (result.success) setEditingCategoryId(null);
return result;
});
};
const moveCategory = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= categories.length) return;
const reordered = [...categories];
const [moved] = reordered.splice(index, 1);
if (!moved) return;
reordered.splice(targetIndex, 0, moved);
run(() => reorderCategoriesAction(reordered.map((c) => c.id)));
};
const moveService = (category: CatalogueCategory, index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= category.services.length) return;
const reordered = [...category.services];
const [moved] = reordered.splice(index, 1);
if (!moved) return;
reordered.splice(targetIndex, 0, moved);
run(() => reorderServicesAction(category.id, reordered.map((s) => s.id)));
};
const openNewService = (categoryId: string) => {
setEditingService(null);
setSheetCategoryId(categoryId);
setSheetOpen(true);
};
const openEditService = (service: CatalogueService) => {
setEditingService({
id: service.id,
categoryId: service.categoryId,
name: service.name,
description: service.description,
durationMinutes: service.durationMinutes,
price: service.price,
bufferBeforeMinutes: service.bufferBeforeMinutes,
bufferAfterMinutes: service.bufferAfterMinutes,
allowOnlineBooking: service.allowOnlineBooking,
});
setSheetCategoryId(service.categoryId);
setSheetOpen(true);
};
return (
<div className="space-y-8">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{categories.map((category, categoryIndex) => (
<section key={category.id}>
<div className="mb-3 flex items-center justify-between px-1">
{editingCategoryId === category.id ? (
<div className="flex flex-1 items-center gap-2">
<Input value={editingCategoryName} onChange={(e) => setEditingCategoryName(e.target.value)} />
<Button size="sm" isLoading={isPending} onClick={() => saveCategoryName(category.id)}>
Save
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<h3 className={category.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{category.name}
</h3>
<button
type="button"
onClick={() => {
setEditingCategoryId(category.id);
setEditingCategoryName(category.name);
}}
className="text-muted"
aria-label={`Rename ${category.name}`}
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
)}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveCategory(categoryIndex, -1)}
disabled={categoryIndex === 0}
aria-label={`Move ${category.name} up`}
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => moveCategory(categoryIndex, 1)}
disabled={categoryIndex === categories.length - 1}
aria-label={`Move ${category.name} down`}
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
<Switch
checked={category.isActive}
onCheckedChange={(checked) => run(() => setCategoryActiveAction(category.id, checked))}
aria-label={`${category.name} active`}
/>
{category.services.length === 0 && (
<button
type="button"
onClick={() => run(() => deleteCategoryAction(category.id))}
className="text-danger"
aria-label={`Delete ${category.name}`}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="rounded-jumbo border-line divide-line divide-y border bg-white">
{category.services.map((service, serviceIndex) => (
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
<div className="flex flex-col">
<button
type="button"
onClick={() => moveService(category, serviceIndex, -1)}
disabled={serviceIndex === 0}
aria-label={`Move ${service.name} up`}
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => moveService(category, serviceIndex, 1)}
disabled={serviceIndex === category.services.length - 1}
aria-label={`Move ${service.name} down`}
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
</div>
<button type="button" onClick={() => openEditService(service)} className="min-w-0 flex-1 text-left">
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{service.name}
</p>
<p className="text-muted mt-0.5 text-xs">
{service.durationMinutes} min · {formatPrice(service.price)}
{!service.allowOnlineBooking && " · Not bookable online"}
</p>
</button>
<Switch
checked={service.isActive}
onCheckedChange={(checked) => run(() => setServiceActiveAction(service.id, checked))}
aria-label={`${service.name} active`}
/>
</div>
))}
<button
type="button"
onClick={() => openNewService(category.id)}
className="text-accent flex w-full items-center gap-3 px-5 py-4 text-xs font-bold tracking-widest uppercase"
>
<Plus className="h-4 w-4" aria-hidden="true" /> Add Service
</button>
</div>
</section>
))}
<div className="flex items-center gap-2">
<Input placeholder="New category name" value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} />
<Button isLoading={isPending} onClick={addCategory}>
Add Category
</Button>
</div>
<ServiceFormSheet
key={editingService?.id ?? "new"}
open={sheetOpen}
onClose={() => setSheetOpen(false)}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
defaultCategoryId={sheetCategoryId}
existing={editingService}
/>
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }[] = [
{ value: "PERCENTAGE", label: "Percentage Discount" },
{ value: "FIXED_AMOUNT", label: "Fixed-Value Discount" },
{ value: "FREE_SERVICE", label: "Free Service" },
];
export interface LoyaltyConfigFormProps {
initialConfig: ProgrammeConfigInput;
initialEligibleServiceIds: string[];
initialEligibleStaffIds: string[];
services: { id: string; name: string }[];
staff: { id: string; name: string }[];
}
export function LoyaltyConfigForm({
initialConfig,
initialEligibleServiceIds,
initialEligibleStaffIds,
services,
staff,
}: LoyaltyConfigFormProps) {
const router = useRouter();
const [config, setConfig] = useState(initialConfig);
const [eligibleServiceIds, setEligibleServiceIds] = useState(initialEligibleServiceIds);
const [eligibleStaffIds, setEligibleStaffIds] = useState(initialEligibleStaffIds);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof ProgrammeConfigInput>(key: K, value: ProgrammeConfigInput[K]) => {
setSaved(false);
setConfig((prev) => ({ ...prev, [key]: value }));
};
const toggleInList = (list: string[], setList: (v: string[]) => void, id: string) => {
setSaved(false);
setList(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await updateLoyaltyConfigAction({ config, eligibleServiceIds, eligibleStaffIds });
if (!result.success) {
setError(result.error ?? "Couldn't save loyalty settings.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="rounded-jumbo border-line border bg-white p-6 lg:col-span-2">
<Switch
label="Loyalty programme enabled"
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
checked={config.isEnabled}
onCheckedChange={(v) => set("isEnabled", v)}
/>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Input
label="Stamps required for reward"
type="number"
min={1}
value={config.stampsRequired}
onChange={(e) => set("stampsRequired", Number(e.target.value))}
/>
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
{REWARD_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
{config.rewardType === "FREE_SERVICE" ? (
<Select
label="Free Service"
value={config.freeServiceId ?? ""}
onChange={(e) => set("freeServiceId", e.target.value)}
>
<option value="">Choose a service…</option>
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
) : (
<Input
label={config.rewardType === "PERCENTAGE" ? "Percentage off (1–100)" : "Fixed amount off"}
value={config.rewardValue ?? ""}
onChange={(e) => set("rewardValue", e.target.value)}
placeholder={config.rewardType === "PERCENTAGE" ? "50" : "10.00"}
/>
)}
<Input label="Reward Name" value={config.rewardLabel ?? ""} onChange={(e) => set("rewardLabel", e.target.value)} placeholder="e.g. 50% Off Any Service" />
<Input
label="Maximum reward value (optional)"
value={config.maxRewardValue ?? ""}
onChange={(e) => set("maxRewardValue", e.target.value)}
hint="Caps a percentage discount so it never exceeds this amount."
/>
</div>
</section>
<section className="space-y-3 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
<div>
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
<div className="space-y-2">
{services.map((s) => (
<Checkbox
key={s.id}
label={s.name}
checked={eligibleServiceIds.includes(s.id)}
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
/>
))}
</div>
</div>
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
<div className="space-y-2">
{staff.map((s) => (
<Checkbox
key={s.id}
label={s.name}
checked={eligibleStaffIds.includes(s.id)}
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
/>
))}
</div>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Switch label="Apply reward automatically" checked={config.autoApplyReward} onCheckedChange={(v) => set("autoApplyReward", v)} />
<Switch
label="Require staff approval to redeem"
description="Redemption already requires a permitted staff member — this is informational only this phase."
checked={config.requiresStaffApproval}
onCheckedChange={(v) => set("requiresStaffApproval", v)}
/>
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
<div className="grid grid-cols-2 gap-4">
<Input
label="Stamps expire after (days, optional)"
type="number"
min={1}
value={config.stampsExpireAfterDays ?? ""}
onChange={(e) => set("stampsExpireAfterDays", e.target.value ? Number(e.target.value) : undefined)}
/>
<Input
label="Reward expires after (days, optional)"
type="number"
min={1}
value={config.rewardExpiresAfterDays ?? ""}
onChange={(e) => set("rewardExpiresAfterDays", e.target.value ? Number(e.target.value) : undefined)}
/>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Card background color" value={config.cardBackgroundColor ?? ""} onChange={(e) => set("cardBackgroundColor", e.target.value)} placeholder="#111b15" />
<Input label="Card background image URL (optional)" value={config.cardBackgroundImageUrl ?? ""} onChange={(e) => set("cardBackgroundImageUrl", e.target.value)} />
<Input label="Stamp icon" value={config.stampIcon ?? ""} onChange={(e) => set("stampIcon", e.target.value)} placeholder="scissors" />
<Input label="Terms line" value={config.termsLine ?? ""} onChange={(e) => set("termsLine", e.target.value)} placeholder="Valid for 12 months. One reward per visit." />
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums";
import { updateOpeningHoursAction } from "../actions/business";
import type { OpeningHoursActionResult } from "../actions/business";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const DAY_LABEL: Record<Weekday, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
function toTimeString(minutes: number | null): string {
if (minutes === null) return "09:00";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function toMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
interface DayState {
dayOfWeek: Weekday;
isClosed: boolean;
startTime: string;
endTime: string;
}
export interface InitialOpeningHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningHoursRow[] }) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => {
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
return {
dayOfWeek,
isClosed: existing?.isClosed ?? true,
startTime: toTimeString(existing?.opensAtMinutes ?? null),
endTime: toTimeString(existing?.closesAtMinutes ?? 19 * 60),
};
}),
);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [conflicts, setConflicts] = useState<NonNullable<OpeningHoursActionResult["conflicts"]>>([]);
const [isPending, startTransition] = useTransition();
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
setSaved(false);
setConflicts([]);
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
};
const copyMondayToAll = () => {
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
if (!monday) return;
setSaved(false);
setConflicts([]);
setDays((prev) =>
prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
);
};
const submit = (acknowledgeConflicts: boolean) => {
setError(null);
startTransition(async () => {
const result = await updateOpeningHoursAction(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isClosed: d.isClosed,
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
})),
acknowledgeConflicts,
);
if (!result.success) {
setError(result.error ?? "Couldn't save opening hours.");
setConflicts(result.conflicts ?? []);
return;
}
setSaved(true);
setConflicts([]);
router.refresh();
});
};
if (conflicts.length > 0) {
return (
<div className="space-y-4">
<p role="alert" className="text-danger text-sm font-medium">
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
the business&apos;s open hours. They won&apos;t be cancelled automatically — review them, then confirm if
you still want to save.
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">
{c.serviceNames} · with {c.staffName}
</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setConflicts([])}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Save Anyway
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to all days
</button>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
<Switch
checked={!day.isClosed}
onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })}
aria-label={`${DAY_LABEL[day.dayOfWeek]} open`}
/>
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
</div>
{day.isClosed ? (
<span className="text-muted text-xs">Closed</span>
) : (
<div className="flex items-center gap-2">
<input
type="time"
value={day.startTime}
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`}
/>
<span className="text-muted text-xs">to</span>
<input
type="time"
value={day.endTime}
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`}
/>
</div>
)}
</div>
))}
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved. The booking engine reflects these hours immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Save
</Button>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { CalendarOff } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { OwnerBlockTimeConflict } from "../actions/calendar";
import { ownerBlockTimeAction } from "../actions/calendar";
function toMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
/** Owner variant of the staff BlockTimeButton (src/features/staff/components/block-time-button.tsx) — same UI, targets a chosen staff member instead of only the signed-in user. */
export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: string; staffName: string; date: string }) {
const [open, setOpen] = useState(false);
const [startTime, setStartTime] = useState("12:00");
const [endTime, setEndTime] = useState("13:00");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<OwnerBlockTimeConflict[] | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleClose = () => {
setOpen(false);
setError(null);
setConflicts(null);
setReason("");
};
const submit = (acknowledgeConflicts: boolean) => {
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
setError(null);
startTransition(async () => {
const result = await ownerBlockTimeAction({ staffId, date, startMinutes, endMinutes, reason, acknowledgeConflicts });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
setOpen(false);
setConflicts(null);
router.refresh();
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" />
Block Time
</Button>
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
<div className="space-y-4 pb-2">
<p className="text-muted text-sm">
Reserve part of{" "}
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "short",
})}{" "}
on {staffName}&apos;s schedule for something other than appointments.
</p>
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} existing appointment
{conflicts.length > 1 ? "s" : ""}:
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Block Anyway
</Button>
</div>
</div>
) : (
<>
<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>
<Input
label="Reason (optional)"
placeholder="e.g. Team meeting"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Block This Time
</Button>
</>
)}
</div>
</BottomSheet>
</>
);
}
@@ -0,0 +1,94 @@
"use client";
import { Gift } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { redeemRewardAction } from "../actions/loyalty";
/**
* Available to any staff member with MANAGE_LOYALTY_STAMPS (Owner and
* Barber both hold it in the seed — see redeemRewardAction) — surfaced on
* both the owner and staff appointment details pages, since redemption is
* an operational action, not owner-only configuration. See
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
*/
export function RedeemRewardButton({
appointmentId,
rewardId,
rewardLabel,
subtotal,
}: {
appointmentId: string;
rewardId: string;
rewardLabel: string;
subtotal: number;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ discountAmount: string } | null>(null);
const [isPending, startTransition] = useTransition();
const close = () => {
const shouldRefresh = !!result;
setOpen(false);
setError(null);
setResult(null);
if (shouldRefresh) router.refresh();
};
const submit = () => {
setError(null);
startTransition(async () => {
const res = await redeemRewardAction({ rewardId, appointmentId });
if (!res.success) {
setError(res.error ?? "Couldn't redeem this reward.");
return;
}
setResult({ discountAmount: res.discountAmount ?? "0.00" });
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Gift className="text-accent-loyalty h-4 w-4" aria-hidden="true" />
Redeem Reward
</Button>
<Modal open={open} onClose={close} title={result ? "Reward Redeemed" : "Redeem Reward"}>
{result ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
{rewardLabel} has been applied to this appointment — £{result.discountAmount} off.
</p>
<Button className="w-full" onClick={close}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
Apply <span className="text-ink font-bold">{rewardLabel}</span> to this appointment
(subtotal £{subtotal.toFixed(2)})? This can&apos;t be undone.
</p>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={close}>
Cancel
</Button>
<Button className="flex-1" isLoading={isPending} onClick={submit}>
Apply Reward
</Button>
</div>
</div>
)}
</Modal>
</>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { createServiceAction, updateServiceAction } from "../actions/catalogue";
export interface EditableService {
id: string;
categoryId: string;
name: string;
description: string;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
allowOnlineBooking: boolean;
}
export function ServiceFormSheet({
open,
onClose,
categories,
defaultCategoryId,
existing,
}: {
open: boolean;
onClose: () => void;
categories: { id: string; name: string }[];
defaultCategoryId: string;
existing?: EditableService | null;
}) {
const router = useRouter();
const [categoryId, setCategoryId] = useState(existing?.categoryId ?? defaultCategoryId);
const [name, setName] = useState(existing?.name ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [duration, setDuration] = useState(String(existing?.durationMinutes ?? 30));
const [price, setPrice] = useState(existing?.price ?? "");
const [bufferBefore, setBufferBefore] = useState(String(existing?.bufferBeforeMinutes ?? 0));
const [bufferAfter, setBufferAfter] = useState(String(existing?.bufferAfterMinutes ?? 0));
const [allowOnlineBooking, setAllowOnlineBooking] = useState(existing?.allowOnlineBooking ?? true);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const submit = () => {
setError(null);
const durationMinutes = Number(duration);
const bufferBeforeMinutes = Number(bufferBefore);
const bufferAfterMinutes = Number(bufferAfter);
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0) {
setError("Duration must be a positive number of minutes.");
return;
}
startTransition(async () => {
const input = {
categoryId,
name,
description: description || undefined,
durationMinutes,
price,
bufferBeforeMinutes,
bufferAfterMinutes,
allowOnlineBooking,
};
const result = existing ? await updateServiceAction(existing.id, input) : await createServiceAction(input);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
onClose();
router.refresh();
});
};
return (
<BottomSheet open={open} onClose={onClose} title={existing ? "Edit Service" : "New Service"}>
<div className="space-y-4 pb-2">
<Select label="Category" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Textarea label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Duration (min)" type="number" min={1} value={duration} onChange={(e) => setDuration(e.target.value)} />
<Input label="Price" placeholder="22.50" value={price} onChange={(e) => setPrice(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Buffer Before (min)" type="number" min={0} value={bufferBefore} onChange={(e) => setBufferBefore(e.target.value)} />
<Input label="Buffer After (min)" type="number" min={0} value={bufferAfter} onChange={(e) => setBufferAfter(e.target.value)} />
</div>
<Switch label="Available for online booking" checked={allowOnlineBooking} onCheckedChange={setAllowOnlineBooking} />
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
{existing ? "Save Changes" : "Add Service"}
</Button>
</div>
</BottomSheet>
);
}
@@ -0,0 +1,282 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select } from "@/components/ui/select";
import {
deactivateStaffAction,
reactivateStaffAction,
regenerateInvitationAction,
setStaffServiceEligibilityAction,
updateStaffAction,
} from "../actions/staff";
export interface StaffDetailPanelProps {
staffId: string;
currentRoleId: string;
roles: { id: string; name: string }[];
allServices: { id: string; name: string }[];
initialServiceIds: string[];
status: "ACTIVE" | "ON_BREAK" | "INACTIVE";
hasPendingInvitation: boolean;
}
type ConflictItem = { id: string; customerName: string; serviceNames: string; startAt: string };
export function StaffDetailPanel({
staffId,
currentRoleId,
roles,
allServices,
initialServiceIds,
status,
hasPendingInvitation,
}: StaffDetailPanelProps) {
const router = useRouter();
const [roleId, setRoleId] = useState(currentRoleId);
const [serviceIds, setServiceIds] = useState(initialServiceIds);
const [roleError, setRoleError] = useState<string | null>(null);
const [roleSaved, setRoleSaved] = useState(false);
const [servicesError, setServicesError] = useState<string | null>(null);
const [servicesSaved, setServicesSaved] = useState(false);
const [serviceConflicts, setServiceConflicts] = useState<ConflictItem[]>([]);
const [deactivateConflicts, setDeactivateConflicts] = useState<ConflictItem[] | null>(null);
const [deactivateError, setDeactivateError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isRolePending, startRoleTransition] = useTransition();
const [isServicesPending, startServicesTransition] = useTransition();
const [isDeactivatePending, startDeactivateTransition] = useTransition();
const [isInvitePending, startInviteTransition] = useTransition();
const saveRole = () => {
setRoleError(null);
setRoleSaved(false);
startRoleTransition(async () => {
const result = await updateStaffAction(staffId, { roleId });
if (!result.success) {
setRoleError(result.error ?? "Couldn't change role.");
return;
}
setRoleSaved(true);
router.refresh();
});
};
const toggleService = (id: string) => {
setServicesSaved(false);
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const saveServices = (acknowledgeConflicts: boolean) => {
setServicesError(null);
startServicesTransition(async () => {
const result = await setStaffServiceEligibilityAction(staffId, serviceIds, acknowledgeConflicts);
if (!result.success) {
setServicesError(result.error ?? "Couldn't update services.");
setServiceConflicts(result.conflicts ?? []);
return;
}
setServicesSaved(true);
setServiceConflicts([]);
router.refresh();
});
};
const deactivate = (acknowledgeConflicts: boolean) => {
setDeactivateError(null);
startDeactivateTransition(async () => {
const result = await deactivateStaffAction(staffId, acknowledgeConflicts);
if (!result.success) {
setDeactivateError(result.error ?? "Couldn't deactivate this staff member.");
setDeactivateConflicts(result.conflicts ?? []);
return;
}
setDeactivateConflicts(null);
router.refresh();
});
};
const reactivate = () => {
startDeactivateTransition(async () => {
await reactivateStaffAction(staffId);
router.refresh();
});
};
const regenerateLink = () => {
startInviteTransition(async () => {
const result = await regenerateInvitationAction(staffId);
if (result.success && result.inviteToken) {
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
}
});
};
const copyLink = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
};
return (
<div className="space-y-6">
{hasPendingInvitation && (
<section className="rounded-jumbo border-warning/30 bg-warning/10 border p-5">
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
<p className="text-muted mt-1 text-xs">
This person hasn&apos;t set a password yet. Email delivery isn&apos;t wired up — send them the link
yourself.
</p>
{inviteLink ? (
<div className="mt-3 space-y-2">
<div className="border-line bg-white break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<Button size="sm" variant="secondary" onClick={copyLink}>
{copied ? "Copied" : "Copy Link"}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" className="mt-3" isLoading={isInvitePending} onClick={regenerateLink}>
Get Invite Link
</Button>
)}
</section>
)}
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role &amp; Permissions</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
{roleError && (
<p role="alert" className="text-danger text-sm font-medium">
{roleError}
</p>
)}
{roleSaved && !roleError && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button size="sm" isLoading={isRolePending} onClick={saveRole} disabled={roleId === currentRoleId}>
Save Role
</Button>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-6">
{allServices.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
{serviceConflicts.length > 0 && (
<div className="border-line space-y-2 border-t pt-3">
<p role="alert" className="text-danger text-sm font-medium">
This staff member has upcoming appointments for a service you&apos;re removing:
</p>
<ul className="space-y-2">
{serviceConflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<Button size="sm" variant="destructive" isLoading={isServicesPending} onClick={() => saveServices(true)}>
Save Anyway
</Button>
</div>
)}
{servicesError && serviceConflicts.length === 0 && (
<p role="alert" className="text-danger text-sm font-medium">
{servicesError}
</p>
)}
{servicesSaved && !servicesError && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
{serviceConflicts.length === 0 && (
<Button size="sm" isLoading={isServicesPending} onClick={() => saveServices(false)}>
Save Services
</Button>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
<div className="rounded-jumbo border-line overflow-hidden border bg-white">
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Working Hours</span>
<span className="text-muted text-xs">Edit</span>
</Link>
</div>
</section>
<section className="pb-2">
{status === "INACTIVE" ? (
<Button variant="secondary" className="w-full" isLoading={isDeactivatePending} onClick={reactivate}>
Reactivate Staff Member
</Button>
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-jumbo border p-5">
<p role="alert" className="text-danger text-sm font-medium">
This staff member still has {deactivateConflicts.length} upcoming appointment
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won&apos;t cancel them — they&apos;ll need to
be reassigned or handled separately.
</p>
<ul className="space-y-2">
{deactivateConflicts.map((c) => (
<li key={c.id} className="rounded-lg border border-white bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setDeactivateConflicts(null)}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isDeactivatePending} onClick={() => deactivate(true)}>
Deactivate Anyway
</Button>
</div>
</div>
) : (
<>
{deactivateError && (
<p role="alert" className="text-danger mb-2 text-sm font-medium">
{deactivateError}
</p>
)}
<Button
variant="destructive"
className="w-full"
isLoading={isDeactivatePending}
onClick={() => deactivate(false)}
>
Deactivate Staff Member
</Button>
</>
)}
</section>
</div>
);
}
@@ -13,11 +13,31 @@ import {
markNoShowAction,
startAppointmentAction,
} from "../actions/appointment-actions";
import type { StaffAppointmentActionResult } from "../actions/appointment-actions";
type RawStatus =
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
type ConfirmKind = "complete" | "no_show" | "cancel";
/** Same shape the owner variant (src/features/owner/actions/appointments.ts) implements — see AppointmentStatusActions' `actions` prop. */
export interface AppointmentActionSet {
confirm: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
checkIn: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
start: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
complete: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
markNoShow: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
cancel: (params: { appointmentId: string; reason?: string }) => Promise<StaffAppointmentActionResult>;
}
const DEFAULT_ACTIONS: AppointmentActionSet = {
confirm: confirmAppointmentAction,
checkIn: checkInAppointmentAction,
start: startAppointmentAction,
complete: completeAppointmentAction,
markNoShow: markNoShowAction,
cancel: cancelStaffAppointmentAction,
};
/**
* The status-appropriate action set — shared by Current Appointment and
* Appointment Details, since both need exactly the same buttons for the
@@ -25,14 +45,19 @@ type ConfirmKind = "complete" | "no_show" | "cancel";
* AppointmentService's own state machine; this component never decides
* whether a transition is legal, only which buttons to *offer* for a given
* status (offering an illegal one would just get rejected server-side —
* see appointment-actions.ts).
* see appointment-actions.ts). `actions` defaults to the staff (own-only)
* Server Actions; the owner appointment details page passes the owner
* variant instead, so this one component serves both contexts without
* forking — see docs/phase-6-summary.md, "Owner appointment access".
*/
export function AppointmentStatusActions({
appointmentId,
status,
actions = DEFAULT_ACTIONS,
}: {
appointmentId: string;
status: RawStatus;
actions?: AppointmentActionSet;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -59,10 +84,10 @@ export function AppointmentStatusActions({
startTransition(async () => {
const result =
confirmKind === "complete"
? await completeAppointmentAction(appointmentId)
? await actions.complete(appointmentId)
: confirmKind === "no_show"
? await markNoShowAction(appointmentId)
: await cancelStaffAppointmentAction({ appointmentId, reason: reason || undefined });
? await actions.markNoShow(appointmentId)
: await actions.cancel({ appointmentId, reason: reason || undefined });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
@@ -101,7 +126,7 @@ export function AppointmentStatusActions({
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => confirmAppointmentAction(appointmentId))}
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
>
Confirm Booking
</Button>
@@ -110,7 +135,7 @@ export function AppointmentStatusActions({
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => checkInAppointmentAction(appointmentId))}
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
>
Check In
</Button>
@@ -119,7 +144,7 @@ export function AppointmentStatusActions({
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => startAppointmentAction(appointmentId))}
onClick={() => runQuiet(() => actions.start(appointmentId))}
>
Start Appointment
</Button>
+9 -1
View File
@@ -15,15 +15,23 @@ export function DateStrip({
businessTimezone,
basePath,
view,
extraParams,
}: {
selectedDate: CalendarDate;
businessTimezone: string;
basePath: string;
view?: string;
/** Preserved across date navigation — e.g. the owner calendar's `?staffId=` filter. */
extraParams?: Record<string, string>;
}) {
const today = todayIn(businessTimezone);
const viewParam = view ? `&view=${view}` : "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`;
const extraParamsString = extraParams
? Object.entries(extraParams)
.map(([key, value]) => `&${key}=${encodeURIComponent(value)}`)
.join("")
: "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}${extraParamsString}`;
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
@@ -52,6 +52,7 @@ export function DayCalendar({
blocks,
workingWindow,
currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
}: {
appointments: CalendarAppointment[];
blocks: CalendarBlock[];
@@ -59,6 +60,8 @@ export function DayCalendar({
workingWindow: { startMinutes: number; endMinutes: number } | null;
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null;
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
appointmentHrefBase?: string;
}) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
@@ -121,7 +124,7 @@ export function DayCalendar({
{appointments.map((appt) => (
<Link
key={appt.id}
href={`/staff/appointments/${appt.id}`}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums";
import { saveWorkingHoursAction } from "../actions/working-hours";
import type { WorkingHoursActionResult } from "../actions/working-hours";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
@@ -52,7 +54,16 @@ export interface InitialWorkingHoursRow {
endMinutes: number | null;
}
export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialRows: InitialWorkingHoursRow[]; businessHoursLabel: Record<Weekday, string> }) {
export function WorkingHoursForm({
initialRows,
businessHoursLabel,
onSave = saveWorkingHoursAction,
}: {
initialRows: InitialWorkingHoursRow[];
businessHoursLabel: Record<Weekday, string>;
/** Owner-on-behalf-of-staff editing (src/app/(owner)/owner/team/[id]/hours) injects its own Server Action here instead of the self-service default — same pattern as AppointmentStatusActions' `actions` prop. */
onSave?: (days: WorkingHoursDayInput[]) => Promise<WorkingHoursActionResult>;
}) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => {
@@ -84,7 +95,7 @@ export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialR
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await saveWorkingHoursAction(
const result = await onSave(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isOff: d.isOff,
+30
View File
@@ -0,0 +1,30 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
/**
* A thin, typed wrapper over the (previously unused, see schema.prisma)
* AuditLog table — used for owner actions that change shared business
* configuration (staff, roles, services, hours, booking rules, loyalty,
* reward redemption). Never stores passwords, invitation tokens, or other
* secrets in `metadata` — callers pass only display-safe before/after
* values.
*/
export async function writeAuditLog(
prisma: PrismaClient,
params: {
actorUserId: string | null;
action: string;
entityType: string;
entityId: string;
metadata?: Prisma.InputJsonValue;
},
): Promise<void> {
await prisma.auditLog.create({
data: {
actorUserId: params.actorUserId,
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
metadata: params.metadata,
},
});
}
+16 -6
View File
@@ -1,14 +1,15 @@
import type { BottomNavItem } from "@/components/navigation/bottom-nav";
/**
* Single source of truth for the staff/owner bottom nav. The templates had
* two competing 4-item sets for what is conceptually the same nav
* Single source of truth for the staff bottom nav. The templates had two
* competing 4-item sets for what is conceptually the same nav
* (Calendar/Current/Clients/Settings on most staff+owner screens vs.
* Dashboard/Staff/Calendar/Admin on the Team Management screen — see
* docs/implementation-plan.md §5/§8). Owner-only screens (team, business
* settings, etc.) are reached from Settings rather than getting their own
* top-level tab, so this one list serves both the (staff) and (owner)
* route groups.
* docs/implementation-plan.md §5/§8). Phase 5 resolved that by having
* (owner) reuse this same list; Phase 6 gives (owner) its own
* OWNER_NAV_ITEMS below instead, per that phase's own explicit nav
* requirement (Dashboard/Calendar/Team/Services/Settings) — see
* docs/phase-6-summary.md, "Owner navigation".
*
* Icons are string keys, not component references — this module is
* imported by Server Component layouts, and a Lucide icon component isn't
@@ -21,3 +22,12 @@ export const STAFF_NAV_ITEMS: BottomNavItem[] = [
{ href: "/staff/clients", label: "Clients", icon: "users" },
{ href: "/staff/settings", label: "Settings", icon: "settings" },
];
/** The (owner) route group's own nav — distinct from STAFF_NAV_ITEMS per the Phase 6 brief. "Team" covers staff management (incl. individual staff detail); "Services" covers categories+services; business/booking-rules/loyalty/notifications live under Settings. */
export const OWNER_NAV_ITEMS: BottomNavItem[] = [
{ href: "/owner", label: "Dashboard", icon: "chart-line" },
{ href: "/owner/calendar", label: "Calendar", icon: "calendar-days" },
{ href: "/owner/team", label: "Team", icon: "users" },
{ href: "/owner/services", label: "Services", icon: "scissors" },
{ href: "/owner/settings", label: "Settings", icon: "settings" },
];
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getOpeningHours, setOpeningHours, updateBusinessProfile } from "../business.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
describe("BusinessService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateBusinessProfile", () => {
it("writes through to the Business and its primary BusinessLocation", async () => {
const result = await updateBusinessProfile(prisma, {
businessId: business.businessId,
profile: {
name: "Renamed Barbershop",
timezone: business.timezone,
currency: "GBP",
locale: "en-GB",
},
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
});
expect(result.success).toBe(true);
const updated = await prisma.business.findUniqueOrThrow({
where: { id: business.businessId },
include: { locations: true },
});
expect(updated.name).toBe("Renamed Barbershop");
expect(updated.locations[0]?.city).toBe("Testville");
});
it("rejects an empty business name without writing anything", async () => {
const result = await updateBusinessProfile(prisma, {
businessId: business.businessId,
profile: { name: " ", timezone: business.timezone, currency: "GBP", locale: "en-GB" },
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("INVALID_INPUT");
const unchanged = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(unchanged.name).toBe("Test Barbershop");
});
});
describe("setOpeningHours", () => {
it("blocks closing a day that has a future confirmed appointment, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const days = [
{ dayOfWeek: Weekday.MONDAY, isClosed: true },
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
];
const blocked = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days,
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
// The appointment is untouched — never auto-cancelled by tightening hours.
const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillConfirmed.status).toBe("CONFIRMED");
const acknowledged = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const hours = await getOpeningHours(prisma, business.businessId);
const mondayHours = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
expect(mondayHours?.isClosed).toBe(true);
// The appointment still exists, unmodified, after the "Save Anyway".
const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(afterSave.status).toBe("CONFIRMED");
});
it("does not flag a conflict for an appointment that falls inside the new hours", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days: [{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 }],
});
expect(result.success).toBe(true);
});
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { validateBusinessProfileInput, validateOpeningHoursInput } from "../business.service";
describe("validateBusinessProfileInput", () => {
const base = {
name: "Blake Heritage Barbershop",
timezone: "Europe/London",
currency: "GBP",
locale: "en-GB",
};
it("accepts a complete profile", () => {
expect(validateBusinessProfileInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateBusinessProfileInput({ ...base, name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a missing timezone/currency/locale", () => {
const result = validateBusinessProfileInput({ ...base, timezone: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
describe("validateOpeningHoursInput", () => {
it("accepts a day marked closed with no times", () => {
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.SUNDAY, isClosed: true }]);
expect(result.valid).toBe(true);
});
it("requires open/close times for an open day", () => {
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.MONDAY, isClosed: false }]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects opening time at or after closing time", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 600, closesAtMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects times outside a single day", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.WEDNESDAY, isClosed: false, opensAtMinutes: 0, closesAtMinutes: 25 * 60 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects a negative opening time", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.THURSDAY, isClosed: false, opensAtMinutes: -30, closesAtMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("accepts a valid full week mixing open and closed days", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 10 * 60, closesAtMinutes: 19 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
]);
expect(result.valid).toBe(true);
});
});
+259
View File
@@ -0,0 +1,259 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums";
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* The business's own profile, address, policies, and opening hours —
* owner-only configuration that sits above everything StaffService and
* BookingRule already cover. Kept separate from BookingRule (scheduling
* policy) and LoyaltyService (loyalty) per the brief's own service
* boundaries; this file only knows about Business/BusinessLocation/
* BusinessSettings/BusinessHours.
*/
export type BusinessServiceErrorCode =
| "INVALID_INPUT"
| "INVALID_TIME_RANGE"
| "APPOINTMENT_CONFLICTS";
export interface BusinessProfileInput {
name: string;
description?: string;
logoUrl?: string;
phone?: string;
email?: string;
website?: string;
instagramUrl?: string;
facebookUrl?: string;
timezone: string;
currency: string;
locale: string;
}
export interface BusinessLocationInput {
addressLine1: string;
addressLine2?: string;
city: string;
region?: string;
postalCode: string;
country: string;
latitude?: number;
longitude?: number;
}
export interface BusinessSettingsInput {
cancellationPolicy?: string;
lateArrivalPolicy?: string;
bookingTerms?: string;
privacyPolicyUrl?: string;
}
export function validateBusinessProfileInput(
input: BusinessProfileInput,
): ValidationResult<BusinessServiceErrorCode> {
if (!input.name.trim()) {
return fail({ code: "INVALID_INPUT", message: "Business name is required." });
}
if (!input.timezone.trim() || !input.currency.trim() || !input.locale.trim()) {
return fail({ code: "INVALID_INPUT", message: "Timezone, currency, and locale are required." });
}
return ok();
}
export async function getBusinessProfile(prisma: PrismaClient, businessId: string) {
return prisma.business.findUnique({
where: { id: businessId },
include: {
locations: { where: { isPrimary: true }, take: 1 },
settings: true,
},
});
}
export async function updateBusinessProfile(
prisma: PrismaClient,
params: {
businessId: string;
profile: BusinessProfileInput;
location: BusinessLocationInput;
},
): Promise<{ success: boolean; validation: ValidationResult<BusinessServiceErrorCode> }> {
const validation = validateBusinessProfileInput(params.profile);
if (!validation.valid) return { success: false, validation };
if (!params.location.addressLine1.trim() || !params.location.city.trim() || !params.location.postalCode.trim()) {
return {
success: false,
validation: fail({ code: "INVALID_INPUT", message: "Address line 1, city, and postcode are required." }),
};
}
const existingLocation = await prisma.businessLocation.findFirst({
where: { businessId: params.businessId, isPrimary: true },
});
await prisma.$transaction([
prisma.business.update({
where: { id: params.businessId },
data: { ...params.profile },
}),
existingLocation
? prisma.businessLocation.update({
where: { id: existingLocation.id },
data: { ...params.location },
})
: prisma.businessLocation.create({
data: { businessId: params.businessId, isPrimary: true, ...params.location },
}),
]);
return { success: true, validation: ok() };
}
export async function updateBusinessSettings(
prisma: PrismaClient,
params: { businessId: string; settings: BusinessSettingsInput },
): Promise<{ success: boolean }> {
await prisma.businessSettings.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...params.settings },
update: { ...params.settings },
});
return { success: true };
}
// ── Opening hours ──────────────────────────────────────────────────────
export interface OpeningHoursDayInput {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes?: number;
closesAtMinutes?: number;
}
export function validateOpeningHoursInput(
days: OpeningHoursDayInput[],
): ValidationResult<BusinessServiceErrorCode> {
for (const day of days) {
if (day.isClosed) continue;
if (day.opensAtMinutes === undefined || day.closesAtMinutes === undefined) {
return fail({
code: "INVALID_INPUT",
message: `${day.dayOfWeek}: opening and closing time are required for an open day.`,
});
}
if (day.opensAtMinutes < 0 || day.closesAtMinutes > 24 * 60) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: times must fall within a single day.`,
});
}
if (day.opensAtMinutes >= day.closesAtMinutes) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: opening time must be before closing time.`,
});
}
}
return ok();
}
export async function getOpeningHours(prisma: PrismaClient, businessId: string) {
return prisma.businessHours.findMany({ where: { businessId }, orderBy: { dayOfWeek: "asc" } });
}
const APPOINTMENT_CONFLICT_INCLUDE = {
services: { include: { service: true } },
customer: true,
staff: { include: { user: { select: { name: true } } } },
} as const;
/**
* Future, non-cancelled appointments that would fall outside the proposed
* hours — either on a day newly marked closed, or before/after the new
* open/close window on a day that stays open. Mirrors the Phase 5
* break/time-off conflict pattern: this never cancels anything itself, it
* only reports what the caller should show before asking for
* acknowledgement.
*/
export async function findOpeningHoursConflicts(
prisma: PrismaClient,
params: { businessId: string; businessTimezone: string; days: OpeningHoursDayInput[] },
) {
const appointments = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
const proposed = byDay.get(weekday);
if (!proposed) return false;
if (proposed.isClosed) return true;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < (proposed.opensAtMinutes ?? 0) || aEnd > (proposed.closesAtMinutes ?? 24 * 60);
});
}
export async function setOpeningHours(
prisma: PrismaClient,
params: {
businessId: string;
businessTimezone: string;
days: OpeningHoursDayInput[];
acknowledgeConflicts?: boolean;
},
) {
const validation = validateOpeningHoursInput(params.days);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findOpeningHoursConflicts(prisma, {
businessId: params.businessId,
businessTimezone: params.businessTimezone,
days: params.days,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "These hours would leave existing appointments outside the business's open hours.",
}),
};
}
}
await prisma.$transaction(
params.days.map((day) =>
prisma.businessHours.upsert({
where: { businessId_dayOfWeek: { businessId: params.businessId, dayOfWeek: day.dayOfWeek } },
create: {
businessId: params.businessId,
dayOfWeek: day.dayOfWeek,
isClosed: day.isClosed,
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
},
update: {
isClosed: day.isClosed,
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
},
}),
),
);
return { success: true as const, conflicts: [], validation: ok() };
}
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createCategory,
deleteCategory,
findServiceUsageConflicts,
updateService,
} from "../service-catalogue.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("ServiceCatalogueService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("categories", () => {
it("blocks deleting a category that still has services", async () => {
// business.categoryId already owns the fixture haircut/beard-trim services.
const result = await deleteCategory(prisma, { categoryId: business.categoryId, businessId: business.businessId });
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_HAS_SERVICES");
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: business.categoryId } });
expect(stillExists).not.toBeNull();
});
it("allows deleting an empty category", async () => {
const created = await createCategory(prisma, { businessId: business.businessId, input: { name: "Empty" } });
if (!created.success) throw new Error("setup failed");
const result = await deleteCategory(prisma, { categoryId: created.category.id, businessId: business.businessId });
expect(result.success).toBe(true);
const gone = await prisma.serviceCategory.findUnique({ where: { id: created.category.id } });
expect(gone).toBeNull();
});
it("scopes deletion to the given business — a category from a different business is not found", async () => {
const otherBusiness = await createTestBusiness();
try {
const result = await deleteCategory(prisma, {
categoryId: otherBusiness.categoryId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
describe("services", () => {
it("editing a service's price never rewrites a past appointment's priceAtBooking snapshot", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const pastAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
include: { services: true },
});
const updated = await updateService(prisma, {
serviceId: business.haircutServiceId,
businessId: business.businessId,
input: {
categoryId: business.categoryId,
name: "Haircut",
durationMinutes: 45,
price: "35.00",
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
allowOnlineBooking: true,
},
});
expect(updated.success).toBe(true);
const snapshot = await prisma.appointmentService.findUniqueOrThrow({
where: { id: pastAppointment.services[0]!.id },
});
expect(Number(snapshot.priceAtBooking)).toBe(20);
expect(snapshot.durationAtBookingMinutes).toBe(30);
const liveService = await prisma.service.findUniqueOrThrow({ where: { id: business.haircutServiceId } });
expect(Number(liveService.price)).toBe(35);
expect(liveService.durationMinutes).toBe(45);
});
it("findServiceUsageConflicts finds only future, non-cancelled appointments booking that service", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const future = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
// Cancelled — must not appear in conflicts.
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CANCELLED",
startAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const conflicts = await findServiceUsageConflicts(prisma, {
serviceId: business.haircutServiceId,
businessId: business.businessId,
});
expect(conflicts.map((c) => c.id)).toEqual([future.id]);
});
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { validateCategoryInput, validateServiceInput } from "../service-catalogue.service";
describe("validateCategoryInput", () => {
it("accepts a valid category name", () => {
expect(validateCategoryInput({ name: "Beard Services" }).valid).toBe(true);
});
it("rejects an empty/whitespace-only name", () => {
const result = validateCategoryInput({ name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
describe("validateServiceInput", () => {
const base = {
categoryId: "cat-1",
name: "Haircut & Style",
durationMinutes: 30,
price: "22.50",
bufferBeforeMinutes: 0,
bufferAfterMinutes: 5,
allowOnlineBooking: true,
};
it("accepts a fully valid service", () => {
expect(validateServiceInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateServiceInput({ ...base, name: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a missing category", () => {
const result = validateServiceInput({ ...base, categoryId: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a zero or negative duration", () => {
const result = validateServiceInput({ ...base, durationMinutes: 0 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
});
it("rejects a non-integer duration", () => {
const result = validateServiceInput({ ...base, durationMinutes: 30.5 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
});
it.each(["free", "-5.00", "5.999", "5,00", ""])("rejects an invalid price %s", (price) => {
const result = validateServiceInput({ ...base, price });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PRICE");
});
it.each(["0", "22", "22.5", "22.50"])("accepts a valid decimal price %s", (price) => {
expect(validateServiceInput({ ...base, price }).valid).toBe(true);
});
it("rejects a negative buffer", () => {
const result = validateServiceInput({ ...base, bufferBeforeMinutes: -1 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
@@ -0,0 +1,322 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* Service categories and services — the owner-facing catalogue. Staff
* eligibility (which barber can perform which service) stays in
* StaffService (see docs/phase-6-summary.md, "Domain services") since it's
* staff-scoped data, not catalogue data; this file only owns
* ServiceCategory/Service themselves.
*
* Money is handled as strings end to end — a price never passes through a
* JS `number` on its way into Prisma's `Decimal` column, so there's no
* floating-point rounding step to get wrong.
*/
export type ServiceCatalogueErrorCode =
| "INVALID_INPUT"
| "INVALID_PRICE"
| "INVALID_DURATION"
| "CATEGORY_NOT_FOUND"
| "CATEGORY_HAS_SERVICES"
| "SERVICE_NOT_FOUND"
| "APPOINTMENT_CONFLICTS";
const DECIMAL_PRICE = /^\d+(\.\d{1,2})?$/;
// ── Categories ──────────────────────────────────────────────────────────
export interface CategoryInput {
name: string;
description?: string;
}
export function validateCategoryInput(input: CategoryInput): ValidationResult<ServiceCatalogueErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Category name is required." });
return ok();
}
export async function listCategories(prisma: PrismaClient, businessId: string) {
return prisma.serviceCategory.findMany({
where: { businessId },
orderBy: { displayOrder: "asc" },
include: { services: { orderBy: { displayOrder: "asc" } } },
});
}
export async function createCategory(
prisma: PrismaClient,
params: { businessId: string; input: CategoryInput },
) {
const validation = validateCategoryInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const maxOrder = await prisma.serviceCategory.aggregate({
where: { businessId: params.businessId },
_max: { displayOrder: true },
});
const category = await prisma.serviceCategory.create({
data: {
businessId: params.businessId,
name: params.input.name.trim(),
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return { success: true as const, category, validation: ok() };
}
export async function updateCategory(
prisma: PrismaClient,
params: { categoryId: string; businessId: string; input: CategoryInput },
) {
const validation = validateCategoryInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const existing = await prisma.serviceCategory.findFirst({
where: { id: params.categoryId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
await prisma.serviceCategory.update({
where: { id: params.categoryId },
data: { name: params.input.name.trim() },
});
return { success: true as const, validation: ok() };
}
export async function setCategoryActive(
prisma: PrismaClient,
params: { categoryId: string; businessId: string; isActive: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.serviceCategory.updateMany({
where: { id: params.categoryId, businessId: params.businessId },
data: { isActive: params.isActive },
});
return { success: result.count > 0 };
}
export async function reorderCategories(
prisma: PrismaClient,
params: { businessId: string; orderedCategoryIds: string[] },
): Promise<{ success: boolean }> {
await prisma.$transaction(
params.orderedCategoryIds.map((id, index) =>
prisma.serviceCategory.updateMany({
where: { id, businessId: params.businessId },
data: { displayOrder: index },
}),
),
);
return { success: true };
}
/** Categories with services (active or not) can't be deleted outright — deactivate instead, per the brief's "prefer deactivation over destructive deletion where historical data depends on it." */
export async function deleteCategory(
prisma: PrismaClient,
params: { categoryId: string; businessId: string },
) {
const existing = await prisma.serviceCategory.findFirst({
where: { id: params.categoryId, businessId: params.businessId },
include: { _count: { select: { services: true } } },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
if (existing._count.services > 0) {
return {
success: false as const,
validation: fail({
code: "CATEGORY_HAS_SERVICES",
message: "This category still has services — move or remove them first, or deactivate the category instead.",
}),
};
}
await prisma.serviceCategory.delete({ where: { id: params.categoryId } });
return { success: true as const, validation: ok() };
}
// ── Services ────────────────────────────────────────────────────────────
export interface ServiceInput {
categoryId: string;
name: string;
description?: string;
durationMinutes: number;
/** Decimal string, e.g. "22.50" — never a JS number, see file header. */
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
allowOnlineBooking: boolean;
}
export function validateServiceInput(input: ServiceInput): ValidationResult<ServiceCatalogueErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Service name is required." });
if (!input.categoryId) return fail({ code: "INVALID_INPUT", message: "Choose a category." });
if (!Number.isInteger(input.durationMinutes) || input.durationMinutes <= 0) {
return fail({ code: "INVALID_DURATION", message: "Duration must be a positive number of minutes." });
}
if (!DECIMAL_PRICE.test(input.price)) {
return fail({ code: "INVALID_PRICE", message: "Enter a price like 22.50, with at most two decimal places." });
}
if (
!Number.isInteger(input.bufferBeforeMinutes) ||
!Number.isInteger(input.bufferAfterMinutes) ||
input.bufferBeforeMinutes < 0 ||
input.bufferAfterMinutes < 0
) {
return fail({ code: "INVALID_INPUT", message: "Buffers must be zero or a positive number of minutes." });
}
return ok();
}
export async function listServices(
prisma: PrismaClient,
params: { businessId: string; categoryId?: string; q?: string },
) {
return prisma.service.findMany({
where: {
businessId: params.businessId,
...(params.categoryId ? { categoryId: params.categoryId } : {}),
...(params.q ? { name: { contains: params.q, mode: "insensitive" } } : {}),
},
include: { category: true },
orderBy: [{ categoryId: "asc" }, { displayOrder: "asc" }],
});
}
export async function createService(
prisma: PrismaClient,
params: { businessId: string; input: ServiceInput },
) {
const validation = validateServiceInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const category = await prisma.serviceCategory.findFirst({
where: { id: params.input.categoryId, businessId: params.businessId },
});
if (!category) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
const maxOrder = await prisma.service.aggregate({
where: { businessId: params.businessId, categoryId: params.input.categoryId },
_max: { displayOrder: true },
});
const service = await prisma.service.create({
data: {
businessId: params.businessId,
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
bufferAfterMinutes: params.input.bufferAfterMinutes,
allowOnlineBooking: params.input.allowOnlineBooking,
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return { success: true as const, service, validation: ok() };
}
const APPOINTMENT_CONFLICT_INCLUDE = {
customer: true,
staff: { include: { user: { select: { name: true } } } },
} as const;
/** Future, non-cancelled appointments that booked this exact service — informational for an edit/deactivation confirmation, never auto-resolved. Historical AppointmentService rows (priceAtBooking/durationAtBookingMinutes) are untouched regardless. */
export async function findServiceUsageConflicts(
prisma: PrismaClient,
params: { serviceId: string; businessId: string },
) {
return prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
services: { some: { serviceId: params.serviceId } },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
}
export async function updateService(
prisma: PrismaClient,
params: { serviceId: string; businessId: string; input: ServiceInput },
) {
const validation = validateServiceInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const existing = await prisma.service.findFirst({
where: { id: params.serviceId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "SERVICE_NOT_FOUND", message: "Service not found." }),
};
}
// Price/duration snapshots on past AppointmentService rows are never
// rewritten — this only ever changes the live Service row future
// bookings will read at booking time. See docs/phase-6-summary.md.
await prisma.service.update({
where: { id: params.serviceId },
data: {
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
bufferAfterMinutes: params.input.bufferAfterMinutes,
allowOnlineBooking: params.input.allowOnlineBooking,
},
});
return { success: true as const, validation: ok() };
}
export async function setServiceActive(
prisma: PrismaClient,
params: { serviceId: string; businessId: string; isActive: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.service.updateMany({
where: { id: params.serviceId, businessId: params.businessId },
data: { isActive: params.isActive },
});
return { success: result.count > 0 };
}
export async function reorderServices(
prisma: PrismaClient,
params: { businessId: string; categoryId: string; orderedServiceIds: string[] },
): Promise<{ success: boolean }> {
await prisma.$transaction(
params.orderedServiceIds.map((id, index) =>
prisma.service.updateMany({
where: { id, businessId: params.businessId, categoryId: params.categoryId },
data: { displayOrder: index },
}),
),
);
return { success: true };
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { validateProgrammeConfig } from "../loyalty.service";
import type { ProgrammeConfigInput } from "../loyalty.service";
const base: ProgrammeConfigInput = {
isEnabled: true,
stampsRequired: 10,
rewardType: "PERCENTAGE",
rewardValue: "50",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
};
describe("validateProgrammeConfig", () => {
it("accepts a valid percentage-reward configuration", () => {
expect(validateProgrammeConfig(base).valid).toBe(true);
});
it("rejects a stamps-required of zero", () => {
const result = validateProgrammeConfig({ ...base, stampsRequired: 0 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("allows an incomplete configuration while disabled", () => {
const result = validateProgrammeConfig({ ...base, isEnabled: false, rewardValue: undefined });
expect(result.valid).toBe(true);
});
it.each([0, 101, -5])("rejects an out-of-range percentage when enabled (%s)", (pct) => {
const result = validateProgrammeConfig({ ...base, rewardValue: String(pct) });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
});
it("rejects a missing percentage value when enabled", () => {
const result = validateProgrammeConfig({ ...base, rewardValue: undefined });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
});
it("accepts a valid fixed-amount reward", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "10.00" });
expect(result.valid).toBe(true);
});
it("rejects a negative fixed-amount reward", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "-1" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_VALUE");
});
it("accepts a free-service reward with a service chosen", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: "svc-1" });
expect(result.valid).toBe(true);
});
it("rejects a free-service reward with no service chosen", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: undefined });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
@@ -0,0 +1,248 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
redeemReward,
setEligibleServices,
updateProgrammeConfig,
} from "../loyalty.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("LoyaltyService — owner configuration and redemption (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateProgrammeConfig", () => {
it("creates the LoyaltyProgramme row on first save and updates it on the next", async () => {
const first = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 8,
rewardType: "PERCENTAGE",
rewardValue: "25",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
expect(first.success).toBe(true);
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(programme.stampsRequired).toBe(8);
expect(Number(programme.rewardValue)).toBe(25);
const second = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 12,
rewardType: "PERCENTAGE",
rewardValue: "40",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
expect(second.success).toBe(true);
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(updated.stampsRequired).toBe(12);
expect(Number(updated.rewardValue)).toBe(40);
});
it("rejects enabling a FREE_SERVICE reward pointing at an inactive service", async () => {
const inactiveService = await prisma.service.update({
where: { id: business.beardTrimServiceId },
data: { isActive: false },
});
const result = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 10,
rewardType: "FREE_SERVICE",
freeServiceId: inactiveService.id,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
describe("setEligibleServices", () => {
it("replaces the eligible-services list wholesale", async () => {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.haircutServiceId] });
let rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
expect(rows.map((r) => r.serviceId)).toEqual([business.haircutServiceId]);
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.beardTrimServiceId] });
rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
expect(rows.map((r) => r.serviceId)).toEqual([business.beardTrimServiceId]);
});
});
describe("redeemReward", () => {
async function setUpRedeemableReward() {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const account = await prisma.loyaltyAccount.create({
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 10 },
});
const reward = await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE" },
});
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
return { staffId, account, reward, appointment };
}
it("applies the discount, marks the reward REDEEMED, and deducts stamps — all atomically", async () => {
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(true);
expect(result.discountAmount).toBe("10.00"); // 50% of a 20.00 haircut
const updatedReward = await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } });
expect(updatedReward.status).toBe("REDEEMED");
expect(updatedReward.redeemedByStaffId).toBe(staffId);
expect(updatedReward.appliedToAppointmentId).toBe(appointment.id);
const updatedAccount = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(updatedAccount.currentStamps).toBe(0);
const updatedAppointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(Number(updatedAppointment.discountAmount)).toBe(10);
});
it("prevents redeeming the same reward twice — the second attempt reports REWARD_NOT_AVAILABLE, not a double deduction", async () => {
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
const first = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(first.success).toBe(true);
// A second appointment to apply to, since the first is now discounted.
const { customerId } = await createTestCustomer(business);
void customerId;
const secondAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId: account.customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const second = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: secondAppointment.id,
staffId,
businessId: business.businessId,
});
expect(second.success).toBe(false);
expect(second.validation.issues[0]?.code).toBe("REWARD_NOT_AVAILABLE");
// Stamps weren't deducted a second time.
const account2 = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(account2.currentStamps).toBe(0);
// The second appointment was never discounted.
const unaffected = await prisma.appointment.findUniqueOrThrow({ where: { id: secondAppointment.id } });
expect(unaffected.discountAmount).toBeNull();
});
it("rejects applying a reward to an appointment that already has a discount", async () => {
const { staffId, reward, appointment } = await setUpRedeemableReward();
await prisma.appointment.update({ where: { id: appointment.id }, data: { discountAmount: 5 } });
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_ELIGIBLE");
});
it("rejects applying a reward to a different customer's appointment", async () => {
const { staffId, reward } = await setUpRedeemableReward();
const { customerId: otherCustomerId } = await createTestCustomer(business);
const otherAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId: otherCustomerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: otherAppointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("REWARD_CUSTOMER_MISMATCH");
});
});
});
+295 -6
View File
@@ -9,11 +9,11 @@ import type { ValidationResult } from "@/lib/validation-result";
* without creating the matching transaction in the same call, and nothing
* outside this file should touch either table directly.
*
* Reward reset-on-redemption (see LoyaltyReward's own schema comment) is
* deliberately not implemented here — redeeming a reward isn't one of the
* Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or
* above `stampsRequired` once a reward becomes available. Redemption is a
* documented Phase 5 limitation, not an oversight.
* Reward redemption (Phase 6) follows the same discipline: redeeming a
* reward, deducting the consumed stamps from the cached total, and
* recording the ledger entry all happen inside one `$transaction` — see
* `redeemReward`. This was a documented, deliberate Phase 5 limitation,
* not an oversight; it's implemented now.
*/
export type LoyaltyErrorCode =
@@ -24,7 +24,15 @@ export type LoyaltyErrorCode =
| "REASON_REQUIRED"
| "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND"
| "APPOINTMENT_NOT_COMPLETED";
| "APPOINTMENT_NOT_COMPLETED"
| "INVALID_INPUT"
| "INVALID_PERCENTAGE"
| "INVALID_VALUE"
| "FREE_SERVICE_REQUIRED"
| "REWARD_NOT_FOUND"
| "REWARD_NOT_AVAILABLE"
| "REWARD_CUSTOMER_MISMATCH"
| "APPOINTMENT_NOT_ELIGIBLE";
export interface LoyaltySummary {
isEnabled: boolean;
@@ -451,3 +459,284 @@ export async function getLoyaltyHistory(
},
});
}
// ── Owner: programme configuration ─────────────────────────────────────
export interface ProgrammeConfigInput {
isEnabled: boolean;
stampsRequired: number;
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
rewardValue?: string;
rewardLabel?: string;
maxRewardValue?: string;
freeServiceId?: string;
autoApplyReward: boolean;
requiresStaffApproval: boolean;
stampsExpireAfterDays?: number;
rewardExpiresAfterDays?: number;
earnOnCancelled: boolean;
earnOnNoShow: boolean;
cardBackgroundColor?: string;
cardBackgroundImageUrl?: string;
stampIcon?: string;
termsLine?: string;
}
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
}
if (!input.isEnabled) return ok();
if (input.rewardType === "PERCENTAGE") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value <= 0 || value > 100) {
return fail({ code: "INVALID_PERCENTAGE", message: "Percentage must be between 1 and 100." });
}
} else if (input.rewardType === "FIXED_AMOUNT") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value < 0) {
return fail({ code: "INVALID_VALUE", message: "Enter a fixed reward amount of zero or more." });
}
} else if (input.rewardType === "FREE_SERVICE" && !input.freeServiceId) {
return fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose which service is free." });
}
return ok();
}
export async function getProgrammeConfig(prisma: PrismaClient, businessId: string) {
return prisma.loyaltyProgramme.findUnique({
where: { businessId },
include: {
eligibleServices: { include: { service: true } },
eligibleStaff: true,
freeService: true,
},
});
}
export async function updateProgrammeConfig(
prisma: PrismaClient,
params: { businessId: string; input: ProgrammeConfigInput },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
const validation = validateProgrammeConfig(params.input);
if (!validation.valid) return { success: false, validation };
if (params.input.isEnabled && params.input.rewardType === "FREE_SERVICE" && params.input.freeServiceId) {
const service = await prisma.service.findFirst({
where: { id: params.input.freeServiceId, businessId: params.businessId, isActive: true },
});
if (!service) {
return {
success: false,
validation: fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose an active service." }),
};
}
}
const data = {
isEnabled: params.input.isEnabled,
stampsRequired: params.input.stampsRequired,
rewardType: params.input.rewardType,
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
rewardLabel: params.input.rewardLabel?.trim() || null,
maxRewardValue: params.input.maxRewardValue || null,
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
autoApplyReward: params.input.autoApplyReward,
requiresStaffApproval: params.input.requiresStaffApproval,
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
earnOnCancelled: params.input.earnOnCancelled,
earnOnNoShow: params.input.earnOnNoShow,
cardBackgroundColor: params.input.cardBackgroundColor || null,
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
stampIcon: params.input.stampIcon || null,
termsLine: params.input.termsLine?.trim() || null,
};
await prisma.loyaltyProgramme.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...data },
update: data,
});
return { success: true, validation: ok() };
}
export async function setEligibleServices(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; serviceIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleService.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleService.createMany({
data: params.serviceIds.map((serviceId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, serviceId })),
}),
]);
return { success: true };
}
/** LoyaltyEligibleStaff.staffId has no FK relation in the schema (see schema.prisma) — callers that need staff names for display fetch StaffProfile rows separately and join by id in memory. */
export async function setEligibleStaff(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; staffIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleStaff.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleStaff.createMany({
data: params.staffIds.map((staffId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, staffId })),
}),
]);
return { success: true };
}
// ── Reward redemption ───────────────────────────────────────────────────
/** Appointments this reward could be applied to right now — same customer, not cancelled, no reward already applied. Shown so staff pick a real, current appointment rather than typing an id. */
export async function listEligibleAppointmentsForReward(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
) {
return prisma.appointment.findMany({
where: {
customerId: params.customerId,
businessId: params.businessId,
status: { notIn: ["CANCELLED"] },
discountAmount: null,
},
include: { services: { include: { service: true } }, staff: { include: { user: { select: { name: true } } } } },
orderBy: { startAt: "desc" },
take: 10,
});
}
/**
* Redeems an AVAILABLE reward against a specific appointment — the one
* place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and
* `Appointment.discountAmount` ever change together. The status flip uses
* a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) inside the
* transaction rather than a read-then-write, so two simultaneous
* redemption attempts can't both succeed — the loser sees
* REWARD_NOT_AVAILABLE, not a corrupted double-deduction.
*
* Neither the reward nor the stamps-required count is snapshotted at
* *earn* time — redemption always uses the loyalty programme's current
* configuration (current stampsRequired, current freeServiceId for a
* FREE_SERVICE reward). A documented simplification: if the owner
* reconfigures the programme between a reward being earned and redeemed,
* redemption reflects the new configuration. See docs/phase-6-summary.md.
*/
export async function redeemReward(
prisma: PrismaClient,
params: { rewardId: string; appointmentId: string; staffId: string; businessId: string },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode>; discountAmount?: string }> {
const reward = await prisma.loyaltyReward.findUnique({
where: { id: params.rewardId },
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
});
if (!reward || reward.loyaltyAccount.loyaltyProgramme.businessId !== params.businessId) {
return { success: false, validation: fail({ code: "REWARD_NOT_FOUND", message: "Reward not found." }) };
}
if (reward.status !== "AVAILABLE") {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
};
}
const appointment = await prisma.appointment.findFirst({
where: { id: params.appointmentId, businessId: params.businessId },
include: { services: true },
});
if (!appointment) {
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
}
if (appointment.customerId !== reward.loyaltyAccount.customerId) {
return {
success: false,
validation: fail({ code: "REWARD_CUSTOMER_MISMATCH", message: "This reward belongs to a different customer." }),
};
}
if (appointment.status === "CANCELLED") {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "Can't apply a reward to a cancelled appointment." }),
};
}
if (appointment.discountAmount !== null) {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "A reward has already been applied to this appointment." }),
};
}
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
let discountAmount: number;
if (reward.rewardType === "PERCENTAGE") {
discountAmount = Math.round(subtotal * (Number(reward.value ?? 0) / 100) * 100) / 100;
} else if (reward.rewardType === "FIXED_AMOUNT") {
discountAmount = Number(reward.value ?? 0);
} else {
const freeLine = appointment.services.find((s) => s.serviceId === reward.loyaltyAccount.loyaltyProgramme.freeServiceId);
if (!freeLine) {
return {
success: false,
validation: fail({
code: "APPOINTMENT_NOT_ELIGIBLE",
message: "This appointment doesn't include the service this reward covers.",
}),
};
}
discountAmount = Number(freeLine.priceAtBooking);
}
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
const discountAmountString = discountAmount.toFixed(2);
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
const updated = await tx.loyaltyReward.updateMany({
where: { id: params.rewardId, status: "AVAILABLE" },
data: {
status: "REDEEMED",
redeemedAt: new Date(),
redeemedByStaffId: params.staffId,
appliedToAppointmentId: params.appointmentId,
},
});
if (updated.count === 0) return { alreadyRedeemed: true };
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: account.id },
data: { currentStamps: { decrement: clampedDeduction } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: account.id,
type: "REWARD_REDEEMED",
amount: -clampedDeduction,
appointmentId: params.appointmentId,
authoredByStaffId: params.staffId,
},
});
await tx.appointment.update({
where: { id: params.appointmentId },
data: { discountAmount: discountAmountString },
});
return { alreadyRedeemed: false };
});
if (alreadyRedeemed) {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed." }),
};
}
return { success: true, validation: ok(), discountAmount: discountAmountString };
}
@@ -0,0 +1,272 @@
import type { PrismaClient } from "@/generated/prisma/client";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { validateProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
/**
* Read-only aggregation for the owner dashboard — every function here
* reads across services/appointments/staff and returns a typed summary;
* nothing in this file writes anything. Per the brief: only exists because
* the dashboard's queries are genuinely more than "call one existing
* service and render it" — this is not a general owner data-access layer,
* and no other feature should import from it.
*/
const ACTIVE_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
export interface DashboardTotals {
total: number;
completed: number;
cancelled: number;
noShow: number;
inProgress: number;
upcoming: number;
}
export interface PopularService {
serviceId: string;
name: string;
count: number;
}
export interface ConfigWarning {
code: string;
message: string;
}
export interface DashboardSummary {
totals: DashboardTotals;
staffWorkingToday: { staffId: string; name: string }[];
staffOnTimeOffToday: { staffId: string; name: string; reason: string | null }[];
/** Booked minutes vs. total working minutes across staff working today — an estimate, not a financial figure, and always labelled as such by the caller. Null when no staff are working today (division by zero). */
occupancy: { bookedMinutes: number; availableMinutes: number; percent: number } | null;
popularServices: PopularService[];
recentLoyaltyActivity: Awaited<ReturnType<typeof getRecentLoyaltyActivity>>;
customersWithAvailableRewards: number;
warnings: ConfigWarning[];
}
async function getRecentLoyaltyActivity(prisma: PrismaClient, businessId: string, limit: number) {
return prisma.loyaltyTransaction.findMany({
where: { loyaltyAccount: { loyaltyProgramme: { businessId } } },
orderBy: { createdAt: "desc" },
take: limit,
include: {
loyaltyAccount: { include: { customer: { select: { firstName: true, lastName: true } } } },
authoredByStaff: { include: { user: { select: { name: true } } } },
},
});
}
export async function getDashboardSummary(
prisma: PrismaClient,
params: { businessId: string; businessTimezone: string; date: CalendarDate },
): Promise<DashboardSummary> {
const appointments = await listAppointments(prisma, {
businessId: params.businessId,
fromDate: params.date,
toDate: params.date,
businessTimezone: params.businessTimezone,
});
const totals: DashboardTotals = {
total: appointments.length,
completed: appointments.filter((a) => a.status === "COMPLETED").length,
cancelled: appointments.filter((a) => a.status === "CANCELLED").length,
noShow: appointments.filter((a) => a.status === "NO_SHOW").length,
inProgress: appointments.filter((a) => a.status === "IN_PROGRESS").length,
upcoming: appointments.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status)).length,
};
const weekday = weekdayOf(params.date);
const dateUtcMidnight = calendarDateToUtcMidnight(params.date);
const [activeStaff, workingHoursToday, timeOffToday] = await Promise.all([
prisma.staffProfile.findMany({
where: { businessId: params.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
prisma.workingHours.findMany({
where: { staff: { businessId: params.businessId, status: "ACTIVE" }, dayOfWeek: weekday },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: params.businessId, status: "ACTIVE" },
status: "APPROVED",
startMinutes: null,
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
]);
const workingHoursByStaffId = new Map(workingHoursToday.map((h) => [h.staffId, h]));
const timeOffStaffIds = new Set(timeOffToday.map((t) => t.staffId));
const nameByStaffId = new Map(activeStaff.map((s) => [s.id, s.user.name ?? "Staff"]));
const staffWorkingToday = activeStaff
.filter((s) => {
const hours = workingHoursByStaffId.get(s.id);
return hours && !hours.isOff && !timeOffStaffIds.has(s.id);
})
.map((s) => ({ staffId: s.id, name: nameByStaffId.get(s.id) ?? "Staff" }));
const staffOnTimeOffToday = timeOffToday.map((t) => ({
staffId: t.staffId,
name: nameByStaffId.get(t.staffId) ?? "Staff",
reason: t.reason,
}));
let occupancy: DashboardSummary["occupancy"] = null;
if (staffWorkingToday.length > 0) {
const availableMinutes = staffWorkingToday.reduce((sum, s) => {
const hours = workingHoursByStaffId.get(s.staffId);
if (!hours || hours.startMinutes === null || hours.endMinutes === null) return sum;
return sum + (hours.endMinutes - hours.startMinutes);
}, 0);
const bookedMinutes = appointments
.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status) || a.status === "COMPLETED")
.reduce((sum, a) => sum + minutesFromMidnight(a.endAt, params.businessTimezone) - minutesFromMidnight(a.startAt, params.businessTimezone), 0);
occupancy = {
bookedMinutes,
availableMinutes,
percent: availableMinutes > 0 ? Math.round((bookedMinutes / availableMinutes) * 100) : 0,
};
}
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recentServiceLines = await prisma.appointmentService.findMany({
where: {
appointment: { businessId: params.businessId, status: "COMPLETED", startAt: { gte: thirtyDaysAgo } },
},
include: { service: { select: { id: true, name: true } } },
});
const serviceCounts = new Map<string, PopularService>();
for (const line of recentServiceLines) {
const existing = serviceCounts.get(line.serviceId);
if (existing) existing.count += 1;
else serviceCounts.set(line.serviceId, { serviceId: line.serviceId, name: line.service.name, count: 1 });
}
const popularServices = [...serviceCounts.values()].sort((a, b) => b.count - a.count).slice(0, 5);
const [recentLoyaltyActivity, customersWithAvailableRewards, warnings] = await Promise.all([
getRecentLoyaltyActivity(prisma, params.businessId, 5),
prisma.loyaltyReward.count({
where: { status: "AVAILABLE", loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } },
}),
getConfigurationWarnings(prisma, params.businessId),
]);
return {
totals,
staffWorkingToday,
staffOnTimeOffToday,
occupancy,
popularServices,
recentLoyaltyActivity,
customersWithAvailableRewards,
warnings,
};
}
export async function getConfigurationWarnings(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
const warnings: ConfigWarning[] = [];
const [businessHours, activeStaff, activeServices, loyaltyProgramme] = await Promise.all([
prisma.businessHours.findMany({ where: { businessId } }),
prisma.staffProfile.findMany({
where: { businessId, status: "ACTIVE" },
include: { user: { select: { name: true } }, workingHours: true, services: true },
}),
prisma.service.findMany({
where: { businessId, isActive: true },
include: { staff: { include: { staff: { select: { status: true } } } } },
}),
prisma.loyaltyProgramme.findUnique({ where: { businessId } }),
]);
if (businessHours.length === 0 || businessHours.every((h) => h.isClosed)) {
warnings.push({ code: "NO_OPENING_HOURS", message: "The business has no open days configured." });
}
for (const staff of activeStaff) {
const hasAnyOpenDay = staff.workingHours.some((h) => !h.isOff);
if (!hasAnyOpenDay) {
warnings.push({
code: "STAFF_NO_WORKING_HOURS",
message: `${staff.user.name ?? "A staff member"} has no working hours set — they can never be booked.`,
});
}
}
for (const service of activeServices) {
const hasActiveEligibleStaff = service.staff.some((s) => s.staff.status === "ACTIVE");
if (!hasActiveEligibleStaff) {
warnings.push({
code: "SERVICE_NO_ELIGIBLE_STAFF",
message: `"${service.name}" is active but no active staff member is eligible to perform it.`,
});
}
}
if (loyaltyProgramme?.isEnabled) {
const validation = validateProgrammeConfig({
isEnabled: loyaltyProgramme.isEnabled,
stampsRequired: loyaltyProgramme.stampsRequired,
rewardType: loyaltyProgramme.rewardType,
rewardValue: loyaltyProgramme.rewardValue?.toString(),
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
autoApplyReward: loyaltyProgramme.autoApplyReward,
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
});
if (!validation.valid) {
warnings.push({
code: "LOYALTY_INCOMPLETE",
message: `The loyalty programme is enabled but incomplete: ${validation.issues[0]?.message}`,
});
}
}
const upcomingTimeOffConflicts = await findUpcomingTimeOffConflicts(prisma, businessId);
warnings.push(...upcomingTimeOffConflicts);
return warnings;
}
/** Whole-day or partial-day time off that overlaps a future, non-cancelled appointment for the same staff member — surfaced as a dashboard warning rather than blocking anything, since the time off may already have been added deliberately (e.g. via Phase 5's own conflict-acknowledge flow). */
async function findUpcomingTimeOffConflicts(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
const upcomingTimeOff = await prisma.timeOff.findMany({
where: {
staff: { businessId },
status: "APPROVED",
endDate: { gte: calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10)) },
},
include: { staff: { include: { user: { select: { name: true } } } } },
});
if (upcomingTimeOff.length === 0) return [];
const warnings: ConfigWarning[] = [];
for (const timeOff of upcomingTimeOff) {
const conflictCount = await prisma.appointment.count({
where: {
staffId: timeOff.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { lt: timeOff.endDate, gte: timeOff.startDate },
},
});
if (conflictCount > 0) {
warnings.push({
code: "TIME_OFF_CONFLICT",
message: `${timeOff.staff.user.name ?? "A staff member"} has ${conflictCount} upcoming appointment${conflictCount === 1 ? "" : "s"} during approved time off.`,
});
}
}
return warnings;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { validateBookingRuleInput } from "../booking-rule.service";
import type { BookingRuleInput } from "../booking-rule.service";
const base: BookingRuleInput = {
minNoticeMinutes: 60,
maxAdvanceDays: 30,
cancellationWindowHours: 24,
rescheduleWindowHours: 24,
slotIntervalMinutes: 15,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
maxAppointmentsPerDay: null,
maxAppointmentsPerStaffPerDay: null,
customerCanChooseStaff: true,
allowGuestBooking: true,
requireApproval: false,
};
describe("validateBookingRuleInput", () => {
it("accepts the default configuration", () => {
expect(validateBookingRuleInput(base).valid).toBe(true);
});
it("rejects a negative minimum notice", () => {
const result = validateBookingRuleInput({ ...base, minNoticeMinutes: -1 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a maximum advance window under 1 day", () => {
const result = validateBookingRuleInput({ ...base, maxAdvanceDays: 0 });
expect(result.valid).toBe(false);
});
it("rejects a negative cancellation window", () => {
expect(validateBookingRuleInput({ ...base, cancellationWindowHours: -1 }).valid).toBe(false);
});
it("rejects a negative reschedule window", () => {
expect(validateBookingRuleInput({ ...base, rescheduleWindowHours: -1 }).valid).toBe(false);
});
it.each([4, 121])("rejects a slot interval outside 5-120 minutes (%s)", (slotIntervalMinutes) => {
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(false);
});
it.each([5, 120])("accepts slot interval boundaries (%s)", (slotIntervalMinutes) => {
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(true);
});
it("rejects a negative buffer", () => {
expect(validateBookingRuleInput({ ...base, bufferBeforeMinutes: -5 }).valid).toBe(false);
});
it("accepts null max-appointments-per-day (unlimited)", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: null }).valid).toBe(true);
});
it("rejects a zero max-appointments-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: 0 }).valid).toBe(false);
});
it("rejects a zero max-appointments-per-staff-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 0 }).valid).toBe(false);
});
it("accepts a positive max-appointments-per-staff-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 5 }).valid).toBe(true);
});
});
@@ -0,0 +1,72 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import type { BookingRuleConfig } from "./business-rules.service";
/**
* Read/write for the BookingRule row itself — deliberately separate from
* business-rules.service.ts, which is pure evaluation logic with no Prisma
* access by design (see that file's own header). This file owns the one
* thing that one doesn't: loading and validating the actual configuration
* an owner edits, which every booking-time check then reads via
* AvailabilityService exactly as before — nothing about how rules are
* *evaluated* changes here, only how they're *set*.
*/
export type BookingRuleErrorCode = "INVALID_INPUT";
export type BookingRuleInput = BookingRuleConfig;
export function validateBookingRuleInput(input: BookingRuleInput): ValidationResult<BookingRuleErrorCode> {
if (!Number.isInteger(input.minNoticeMinutes) || input.minNoticeMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Minimum notice can't be negative." });
}
if (!Number.isInteger(input.maxAdvanceDays) || input.maxAdvanceDays < 1) {
return fail({ code: "INVALID_INPUT", message: "Maximum advance booking must be at least 1 day." });
}
if (!Number.isInteger(input.cancellationWindowHours) || input.cancellationWindowHours < 0) {
return fail({ code: "INVALID_INPUT", message: "Cancellation window can't be negative." });
}
if (!Number.isInteger(input.rescheduleWindowHours) || input.rescheduleWindowHours < 0) {
return fail({ code: "INVALID_INPUT", message: "Reschedule window can't be negative." });
}
if (!Number.isInteger(input.slotIntervalMinutes) || input.slotIntervalMinutes < 5 || input.slotIntervalMinutes > 120) {
return fail({ code: "INVALID_INPUT", message: "Slot interval must be between 5 and 120 minutes." });
}
if (!Number.isInteger(input.bufferBeforeMinutes) || input.bufferBeforeMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Buffer before can't be negative." });
}
if (!Number.isInteger(input.bufferAfterMinutes) || input.bufferAfterMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Buffer after can't be negative." });
}
if (input.maxAppointmentsPerDay !== null && (!Number.isInteger(input.maxAppointmentsPerDay) || input.maxAppointmentsPerDay < 1)) {
return fail({ code: "INVALID_INPUT", message: "Max appointments per day must be at least 1, or left unlimited." });
}
if (
input.maxAppointmentsPerStaffPerDay !== null &&
(!Number.isInteger(input.maxAppointmentsPerStaffPerDay) || input.maxAppointmentsPerStaffPerDay < 1)
) {
return fail({ code: "INVALID_INPUT", message: "Max appointments per staff member per day must be at least 1, or left unlimited." });
}
return ok();
}
export async function getBookingRule(prisma: PrismaClient, businessId: string) {
return prisma.bookingRule.findUnique({ where: { businessId } });
}
export async function updateBookingRule(
prisma: PrismaClient,
params: { businessId: string; input: BookingRuleInput },
): Promise<{ success: boolean; validation: ValidationResult<BookingRuleErrorCode> }> {
const validation = validateBookingRuleInput(params.input);
if (!validation.valid) return { success: false, validation };
await prisma.bookingRule.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...params.input },
update: { ...params.input },
});
return { success: true, validation: ok() };
}
@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createStaffProfile,
deactivateStaff,
reactivateStaff,
setStaffServiceEligibility,
updateStaffAsOwner,
} from "../staff.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Covers the owner-only staff management additions from Phase 6:
* onboarding, deactivation/last-owner protection, and service-eligibility
* conflict review. Uses createTestStaff's auto-created role (no
* MANAGE_STAFF permission) as the "ordinary barber" role, and a
* hand-created role with MANAGE_STAFF as the "owner" role — mirroring how
* countOtherActiveManagers/staffCurrentlyManagesStaff actually define "is
* an owner" (see staff.service.ts).
*/
describe("StaffService — owner actions (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function createOwnerRole() {
return prisma.role.create({
data: {
businessId: business.businessId,
name: "Owner",
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
},
});
}
async function createBarberRole() {
return prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
}
describe("createStaffProfile", () => {
it("creates a fully usable User + StaffProfile + eligibility in one go", async () => {
const role = await createBarberRole();
const result = await createStaffProfile(prisma, {
businessId: business.businessId,
input: {
name: "New Barber",
email: "new.barber@test.local",
roleId: role.id,
serviceIds: [business.haircutServiceId],
isBookableOnline: true,
},
});
expect(result.success).toBe(true);
if (!result.success) throw new Error("unreachable");
business.createdUserIds.push(result.staff.userId);
const eligibility = await prisma.staffService.findMany({ where: { staffId: result.staff.id } });
expect(eligibility.map((e) => e.serviceId)).toEqual([business.haircutServiceId]);
});
it("rejects an email already in use", async () => {
const role = await createBarberRole();
const { userId } = await createTestStaff(business);
const existingUser = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const result = await createStaffProfile(prisma, {
businessId: business.businessId,
input: {
name: "Duplicate",
email: existingUser.email!,
roleId: role.id,
serviceIds: [],
isBookableOnline: true,
},
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("EMAIL_ALREADY_IN_USE");
});
});
describe("last-owner protection", () => {
it("blocks deactivating the only active owner", async () => {
const ownerRole = await createOwnerRole();
const { staffId: ownerStaffId, userId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
void userId;
const result = await deactivateStaff(prisma, { staffId: ownerStaffId, businessId: business.businessId });
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
const stillActive = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
expect(stillActive.status).toBe("ACTIVE");
});
it("allows deactivating an owner when another active owner remains", async () => {
const ownerRole = await createOwnerRole();
const { staffId: firstOwnerId } = await createTestStaff(business);
const { staffId: secondOwnerId } = await createTestStaff(business);
await prisma.staffProfile.updateMany({
where: { id: { in: [firstOwnerId, secondOwnerId] } },
data: { roleId: ownerRole.id },
});
const result = await deactivateStaff(prisma, { staffId: firstOwnerId, businessId: business.businessId });
expect(result.success).toBe(true);
const deactivated = await prisma.staffProfile.findUniqueOrThrow({ where: { id: firstOwnerId } });
expect(deactivated.status).toBe("INACTIVE");
});
it("blocks demoting the only active owner's role away from MANAGE_STAFF", async () => {
const ownerRole = await createOwnerRole();
const barberRole = await createBarberRole();
const { staffId: ownerStaffId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
const result = await updateStaffAsOwner(prisma, {
staffId: ownerStaffId,
businessId: business.businessId,
input: { roleId: barberRole.id },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
const stillOwner = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
expect(stillOwner.roleId).toBe(ownerRole.id);
});
it("reactivating a deactivated staff member restores ACTIVE status", async () => {
const { staffId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: staffId }, data: { status: "INACTIVE" } });
const result = await reactivateStaff(prisma, { staffId, businessId: business.businessId });
expect(result.success).toBe(true);
const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } });
expect(staff.status).toBe("ACTIVE");
});
});
describe("deactivation appointment conflicts", () => {
it("blocks deactivating a staff member with a future appointment, and Save Anyway bypasses it without cancelling it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const blocked = await deactivateStaff(prisma, { staffId, businessId: business.businessId });
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const acknowledged = await deactivateStaff(prisma, {
staffId,
businessId: business.businessId,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const stillThere = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillThere.status).toBe("CONFIRMED");
expect(stillThere.staffId).toBe(staffId);
});
});
describe("setStaffServiceEligibility", () => {
it("blocks removing a service that has a future appointment booked, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
// Existing fixture staff is eligible for both haircut and beard trim
// (see createTestStaff) — removing haircut should conflict.
const blocked = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.beardTrimServiceId],
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.length).toBeGreaterThan(0);
const acknowledged = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.beardTrimServiceId],
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const eligibility = await prisma.staffService.findMany({ where: { staffId } });
expect(eligibility.map((e) => e.serviceId)).toEqual([business.beardTrimServiceId]);
});
it("adding a service never triggers a conflict", async () => {
const { staffId } = await createTestStaff(business);
const result = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
});
expect(result.success).toBe(true);
});
});
});
@@ -4,6 +4,7 @@ import {
StaffPermissionDeniedError,
assertOwnsResource,
validateBreakInput,
validateCreateStaffInput,
validateTimeOffInput,
validateWorkingHoursInput,
} from "../staff.service";
@@ -135,3 +136,38 @@ describe("validateTimeOffInput", () => {
expect(result.valid).toBe(true);
});
});
describe("validateCreateStaffInput", () => {
const base = {
name: "Owen Clarke",
email: "owen.clarke@blakeheritage.co.uk",
roleId: "role-barber",
serviceIds: [] as string[],
isBookableOnline: true,
};
it("accepts a valid new-staff input", () => {
expect(validateCreateStaffInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateCreateStaffInput({ ...base, name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it.each(["not-an-email", "missing-domain@", "@missing-local.com", ""])(
"rejects an invalid email %s",
(email) => {
const result = validateCreateStaffInput({ ...base, email });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
},
);
it("rejects a missing role", () => {
const result = validateCreateStaffInput({ ...base, roleId: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
@@ -0,0 +1,103 @@
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import type { PrismaClient } from "@/generated/prisma/client";
/**
* The "invite a new staff member" flow, deliberately split out of
* StaffService (see docs/phase-6-summary.md, "Domain services" — the
* brief's own instruction not to mix invitation delivery into StaffService).
* StaffService.createStaffProfile makes the User/StaffProfile usable
* immediately; this file only tracks the separate "has this person set
* their own password yet" signal via StaffInvitation.acceptedAt.
*
* No real email delivery this phase — `token` is a plain opaque value
* (crypto-random, not HMAC-signed like the booking-draft cookie, because
* it's validated against a database row rather than needing to be
* self-contained) surfaced to the owner as a copy-link
* (`/invite/accept?token=...`) to send however they like. This is a
* documented development-safe shortcut, not a production email system.
*/
const INVITATION_TTL_DAYS = 7;
export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED" | "INVALID_INPUT";
function generateToken(): string {
return randomBytes(32).toString("base64url");
}
export async function createInvitation(
prisma: PrismaClient,
params: { businessId: string; staffId: string; email: string; invitedByUserId?: string },
) {
return prisma.staffInvitation.create({
data: {
businessId: params.businessId,
staffId: params.staffId,
email: params.email,
token: generateToken(),
invitedByUserId: params.invitedByUserId,
expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000),
},
});
}
/** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */
export async function regenerateInvitation(
prisma: PrismaClient,
params: { staffId: string; businessId: string },
) {
const existing = await prisma.staffInvitation.findFirst({
where: { staffId: params.staffId, businessId: params.businessId },
});
if (!existing) return null;
return prisma.staffInvitation.update({
where: { id: existing.id },
data: { token: generateToken(), expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), acceptedAt: null },
});
}
export async function getInvitationByToken(prisma: PrismaClient, token: string) {
return prisma.staffInvitation.findUnique({
where: { token },
include: {
staff: { include: { user: { select: { id: true, name: true, email: true } }, business: { select: { name: true } } } },
},
});
}
export type InvitationCheck =
| { valid: true; invitation: NonNullable<Awaited<ReturnType<typeof getInvitationByToken>>> }
| { valid: false; reason: StaffInvitationErrorCode };
export async function validateInvitationToken(prisma: PrismaClient, token: string): Promise<InvitationCheck> {
const invitation = await getInvitationByToken(prisma, token);
if (!invitation) return { valid: false, reason: "INVALID_TOKEN" };
if (invitation.acceptedAt) return { valid: false, reason: "ALREADY_ACCEPTED" };
if (invitation.expiresAt < new Date()) return { valid: false, reason: "EXPIRED" };
return { valid: true, invitation };
}
export async function acceptInvitation(
prisma: PrismaClient,
params: { token: string; password: string },
): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
const check = await validateInvitationToken(prisma, params.token);
if (!check.valid) return { success: false, reason: check.reason };
const passwordHash = await bcrypt.hash(params.password, 12);
await prisma.$transaction([
prisma.user.update({
where: { id: check.invitation.staff.userId },
data: { passwordHash, emailVerified: new Date() },
}),
prisma.staffInvitation.update({
where: { id: check.invitation.id },
data: { acceptedAt: new Date() },
}),
]);
return { success: true };
}
+361 -1
View File
@@ -31,7 +31,10 @@ export type StaffServiceErrorCode =
| "BREAK_NOT_FOUND"
| "TIME_OFF_NOT_FOUND"
| "APPOINTMENT_CONFLICTS"
| "PERMISSION_DENIED";
| "PERMISSION_DENIED"
| "EMAIL_ALREADY_IN_USE"
| "ROLE_NOT_FOUND"
| "LAST_OWNER";
export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to manage this staff member's schedule.") {
@@ -625,3 +628,360 @@ export async function setNotificationPreferences(
);
return { success: true };
}
// ── Owner: team management ─────────────────────────────────────────────
// Everything below acts on staff *other than* the caller — the owner's own
// business-membership must already be verified by the caller (see
// src/features/owner/authorization.ts); every function here additionally
// re-scopes by businessId itself rather than trusting the caller alone.
export async function listStaffForBusiness(
prisma: PrismaClient,
params: { businessId: string; q?: string; includeInactive?: boolean },
) {
return prisma.staffProfile.findMany({
where: {
businessId: params.businessId,
...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
...(params.q
? {
user: {
OR: [
{ name: { contains: params.q, mode: "insensitive" } },
{ email: { contains: params.q, mode: "insensitive" } },
],
},
}
: {}),
},
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { select: { id: true, name: true } },
invitation: true,
},
orderBy: { createdAt: "asc" },
});
}
export async function getStaffDetailById(prisma: PrismaClient, params: { staffId: string; businessId: string }) {
return prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { include: { permissions: true } },
services: { include: { service: true } },
invitation: true,
},
});
}
export async function listRolesForBusiness(prisma: PrismaClient, businessId: string) {
return prisma.role.findMany({
where: { businessId },
include: { permissions: true },
orderBy: { name: "asc" },
});
}
/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */
async function countOtherActiveManagers(
prisma: PrismaClient,
params: { businessId: string; excludeStaffId: string },
) {
return prisma.staffProfile.count({
where: {
businessId: params.businessId,
status: "ACTIVE",
id: { not: params.excludeStaffId },
role: { permissions: { some: { permission: "MANAGE_STAFF" } } },
},
});
}
async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise<boolean> {
const staff = await prisma.staffProfile.findUnique({
where: { id: staffId },
select: { status: true, role: { select: { permissions: { select: { permission: true } } } } },
});
return (
!!staff &&
staff.status === "ACTIVE" &&
staff.role.permissions.some((p) => p.permission === "MANAGE_STAFF")
);
}
export interface CreateStaffInput {
name: string;
email: string;
phone?: string;
image?: string;
bio?: string;
roleId: string;
serviceIds: string[];
isBookableOnline: boolean;
}
export function validateCreateStaffInput(input: CreateStaffInput): ValidationResult<StaffServiceErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Name is required." });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." });
}
if (!input.roleId) return fail({ code: "INVALID_INPUT", message: "Choose a role." });
return ok();
}
/** Creates the User + StaffProfile + initial service eligibility immediately — the profile is fully usable right away. Working hours default to "off every day" until the owner (or the staff member, once they've accepted) sets them; StaffInvitationService is what tracks acceptance, deliberately kept separate — see that file. */
export async function createStaffProfile(
prisma: PrismaClient,
params: { businessId: string; input: CreateStaffInput },
) {
const validation = validateCreateStaffInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const [role, existingUser] = await Promise.all([
prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }),
prisma.user.findUnique({ where: { email: params.input.email.toLowerCase() } }),
]);
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
if (existingUser) {
return {
success: false as const,
validation: fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }),
};
}
const user = await prisma.user.create({
data: {
name: params.input.name.trim(),
email: params.input.email.toLowerCase(),
phone: params.input.phone || null,
image: params.input.image || null,
staffProfile: {
create: {
businessId: params.businessId,
roleId: params.input.roleId,
bio: params.input.bio?.trim() || null,
isBookableOnline: params.input.isBookableOnline,
services: { create: params.input.serviceIds.map((serviceId) => ({ serviceId })) },
},
},
},
include: { staffProfile: true },
});
return { success: true as const, staff: user.staffProfile!, validation: ok() };
}
export interface UpdateStaffAsOwnerInput {
name?: string;
phone?: string;
image?: string;
bio?: string;
isBookableOnline?: boolean;
roleId?: string;
}
export async function updateStaffAsOwner(
prisma: PrismaClient,
params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) };
}
if (params.input.roleId && params.input.roleId !== existing.roleId) {
const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
where: { roleId: role.id, permission: "MANAGE_STAFF" },
}));
if (wasManager && !stillManagesAfter) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
};
}
}
}
await prisma.$transaction([
prisma.user.update({
where: { id: existing.userId },
data: {
...(params.input.name !== undefined ? { name: params.input.name } : {}),
...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
},
}),
prisma.staffProfile.update({
where: { id: params.staffId },
data: {
...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
...(params.input.isBookableOnline !== undefined
? { isBookableOnline: params.input.isBookableOnline }
: {}),
...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
},
}),
]);
return { success: true as const, validation: ok() };
}
/** Future, non-cancelled appointments still assigned to this staff member — shown for acknowledgement before deactivating, never auto-resolved. Deactivating does not touch these rows; it only removes the staff member from future booking/availability. */
export async function findStaffDeactivationConflicts(
prisma: PrismaClient,
params: { staffId: string },
) {
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function deactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
if (wasManager) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
conflicts: [],
};
}
}
if (!params.acknowledgeConflicts) {
const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId });
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member still has upcoming appointments.",
}),
};
}
}
await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
return { success: true as const, conflicts: [], validation: ok() };
}
export async function reactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string },
): Promise<{ success: boolean }> {
const result = await prisma.staffProfile.updateMany({
where: { id: params.staffId, businessId: params.businessId },
data: { status: "ACTIVE" },
});
return { success: result.count > 0 };
}
/** Future, non-cancelled appointments this staff member has that book a service being *removed* from their eligible list — the only ones that matter, since keeping or adding eligibility can never orphan an existing booking. */
export async function findServiceEligibilityConflicts(
prisma: PrismaClient,
params: { staffId: string; removedServiceIds: string[] },
) {
if (params.removedServiceIds.length === 0) return [];
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
services: { some: { serviceId: { in: params.removedServiceIds } } },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function setStaffServiceEligibility(
prisma: PrismaClient,
params: { staffId: string; businessId: string; serviceIds: string[]; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: { services: true },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const currentServiceIds = existing.services.map((s) => s.serviceId);
const removedServiceIds = currentServiceIds.filter((id) => !params.serviceIds.includes(id));
if (!params.acknowledgeConflicts) {
const conflicts = await findServiceEligibilityConflicts(prisma, {
staffId: params.staffId,
removedServiceIds,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member has upcoming appointments for a service you're removing.",
}),
};
}
}
await prisma.$transaction([
prisma.staffService.deleteMany({ where: { staffId: params.staffId } }),
prisma.staffService.createMany({
data: params.serviceIds.map((serviceId) => ({ staffId: params.staffId, serviceId })),
}),
]);
return { success: true as const, conflicts: [], validation: ok() };
}