This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

+8
View File
@@ -4,6 +4,8 @@ import bcrypt from "bcryptjs";
import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { registerSchema } from "@/features/auth/schemas";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
export interface RegisterResult {
success: boolean;
@@ -19,6 +21,12 @@ export interface RegisterResult {
* docs/implementation-plan.md §15, Question 3-adjacent).
*/
export async function registerAction(formData: FormData): Promise<RegisterResult> {
const ip = await getRequestIp();
const { allowed } = checkRateLimit(`register:${ip}`, { maxAttempts: 5, windowMs: 60 * 60 * 1000 });
if (!allowed) {
return { success: false, error: "Too many attempts. Please try again later." };
}
const parsed = registerSchema.safeParse({
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
@@ -1,35 +1,104 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionDeniedError, assertCanAccessAppointment, canAccessAppointment } from "../authorization";
/**
* The registered-customer branch never touches the database (it's a plain
* userId comparison), so it's tested here in isolation. The guest-token
* branch's real hash/expiry/revocation behaviour is covered against a real
* database in customer-booking.integration.test.ts — this file only checks
* that authorization.ts calls into validateGuestAccessToken correctly and
* respects its result.
*/
vi.mock("@/services/scheduling/guest-access.service", () => ({
validateGuestAccessToken: vi.fn(),
}));
const { validateGuestAccessToken } = await import("@/services/scheduling/guest-access.service");
const mockValidate = vi.mocked(validateGuestAccessToken);
beforeEach(() => {
mockValidate.mockClear();
});
// Not a real PrismaClient — the mocked guest-access service never touches it.
const prisma = {} as never;
describe("assertCanAccessAppointment", () => {
it("allows a registered customer to access their own appointment", () => {
expect(() =>
assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_1"),
).not.toThrow();
it("allows a registered customer to access their own appointment", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
).resolves.toBeUndefined();
});
it("denies a different signed-in user from accessing a registered customer's appointment", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toThrow(
PermissionDeniedError,
);
it("denies a different signed-in user from accessing a registered customer's appointment", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
).rejects.toThrow(PermissionDeniedError);
});
it("denies an unauthenticated caller from accessing a registered customer's appointment", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, undefined)).toThrow(
PermissionDeniedError,
);
it("denies an unauthenticated caller from accessing a registered customer's appointment", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: undefined }),
).rejects.toThrow(PermissionDeniedError);
});
it("allows access to a guest appointment (no linked user) regardless of session — the documented guest access model", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, undefined)).not.toThrow();
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, "some_other_user")).not.toThrow();
it("never even calls the guest-token validator for a registered customer's appointment", async () => {
mockValidate.mockClear();
await assertCanAccessAppointment(
prisma,
{ id: "a1", customer: { userId: "user_1" } },
{ sessionUserId: "user_1" },
).catch(() => {});
expect(mockValidate).not.toHaveBeenCalled();
});
it("allows a guest appointment when the guest-token validator confirms the token", async () => {
mockValidate.mockResolvedValueOnce(true);
await expect(
assertCanAccessAppointment(
prisma,
{ id: "a1", customer: { userId: null } },
{ sessionUserId: undefined, guestToken: "correct-token" },
),
).resolves.toBeUndefined();
expect(mockValidate).toHaveBeenCalledWith(prisma, { appointmentId: "a1", rawToken: "correct-token" });
});
it("denies a guest appointment when no token is supplied at all — the appointment id is no longer sufficient", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: null } }, { sessionUserId: undefined }),
).rejects.toThrow(PermissionDeniedError);
expect(mockValidate).not.toHaveBeenCalled();
});
it("denies a guest appointment when the guest-token validator rejects the token", async () => {
mockValidate.mockResolvedValueOnce(false);
await expect(
assertCanAccessAppointment(
prisma,
{ id: "a1", customer: { userId: null } },
{ sessionUserId: undefined, guestToken: "wrong-token" },
),
).rejects.toThrow(PermissionDeniedError);
});
});
describe("canAccessAppointment", () => {
it("mirrors assertCanAccessAppointment as a boolean", () => {
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_1")).toBe(true);
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toBe(false);
expect(canAccessAppointment({ customer: { userId: null } }, undefined)).toBe(true);
it("mirrors assertCanAccessAppointment as a boolean", async () => {
expect(
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
).toBe(true);
expect(
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
).toBe(false);
mockValidate.mockResolvedValueOnce(true);
expect(
await canAccessAppointment(
prisma,
{ id: "a1", customer: { userId: null } },
{ sessionUserId: undefined, guestToken: "correct-token" },
),
).toBe(true);
});
});
@@ -4,6 +4,7 @@ import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/busi
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { revokeGuestAccessToken } from "@/services/scheduling/guest-access.service";
import {
cleanupTestBusiness,
createTestBusiness,
@@ -357,10 +358,16 @@ describe("customer booking flows (integration)", () => {
});
if (!created.success) throw new Error("setup failed");
expect(() => assertCanAccessAppointment(created.appointment, ownerUserId)).not.toThrow();
expect(() => assertCanAccessAppointment(created.appointment, strangerUserId)).toThrow(PermissionDeniedError);
expect(() => assertCanAccessAppointment(created.appointment, undefined)).toThrow(PermissionDeniedError);
expect(canAccessAppointment(created.appointment, strangerUserId)).toBe(false);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: ownerUserId }),
).resolves.toBeUndefined();
await expect(
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId }),
).rejects.toThrow(PermissionDeniedError);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: undefined }),
).rejects.toThrow(PermissionDeniedError);
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId })).toBe(false);
});
it("does not treat the appointment id alone as permission — an unrelated session cannot claim it", async () => {
@@ -378,7 +385,102 @@ describe("customer booking flows (integration)", () => {
expect(created.appointment.customer.userId).toBe(ownerUserId);
const randomOtherId = "some-other-user-id-that-is-not-the-owner";
expect(canAccessAppointment(created.appointment, randomOtherId)).toBe(false);
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: randomOtherId })).toBe(false);
});
});
describe("guest access tokens (Phase 7 — see guest-access.service.ts)", () => {
it("a guest booking issues a token, and only the correct token grants access", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
expect(created.guestAccessToken).toBeTruthy();
expect(created.appointment.customer.userId).toBeNull();
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).resolves.toBeUndefined();
// The bare appointment id is no longer sufficient — the exact gap this closes.
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: undefined }),
).rejects.toThrow(PermissionDeniedError);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: "totally-wrong-token" }),
).rejects.toThrow(PermissionDeniedError);
});
it("a token scoped to one guest appointment does not grant access to another", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
const second = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
});
if (!first.success || !second.success) throw new Error("setup failed");
expect(first.guestAccessToken).not.toBe(second.guestAccessToken);
await expect(
assertCanAccessAppointment(prisma, second.appointment, { guestToken: first.guestAccessToken }),
).rejects.toThrow(PermissionDeniedError);
});
it("an expired or revoked token is rejected even though it's otherwise correct", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
await prisma.guestAppointmentAccessToken.update({
where: { appointmentId: created.appointment.id },
data: { expiresAt: new Date(Date.now() - 1000) },
});
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).rejects.toThrow(PermissionDeniedError);
await prisma.guestAppointmentAccessToken.update({
where: { appointmentId: created.appointment.id },
data: { expiresAt: new Date(Date.now() + 1000 * 60 * 60), revokedAt: null },
});
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).resolves.toBeUndefined();
await revokeGuestAccessToken(prisma, created.appointment.id);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).rejects.toThrow(PermissionDeniedError);
});
});
+14 -1
View File
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { clearDraft, readDraft, writeLastBookingReference } from "@/lib/booking-draft/cookie";
import { prisma } from "@/lib/db/prisma";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
import { createBooking } from "@/services/scheduling/booking.service";
import { getFirstFriendlyMessage } from "../error-messages";
@@ -42,6 +44,14 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
if (!draft.guest) {
return { success: false, error: "Please provide your contact details first." };
}
// Registered customers already have a rate-limited signin gate on
// their account; a guest has none, so this is the one place spam
// bookings can otherwise be generated with no account at all.
const ip = await getRequestIp();
const { allowed } = checkRateLimit(`guest-booking:${ip}`, { maxAttempts: 8, windowMs: 60 * 60 * 1000 });
if (!allowed) {
return { success: false, error: "Too many booking attempts. Please try again later." };
}
const resolved = await resolveCustomerForBooking(prisma, { businessId, guest: draft.guest });
customerId = resolved.customerId;
}
@@ -62,7 +72,10 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
await writeLastBookingReference(result.appointment.id);
await writeLastBookingReference({
appointmentId: result.appointment.id,
guestAccessToken: result.guestAccessToken,
});
await clearDraft();
redirect("/book/confirmation");
}
@@ -31,7 +31,10 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, {
sessionUserId: session?.user?.id,
guestToken: parsed.data.guestToken,
});
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
throw error;
@@ -65,7 +68,10 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, {
sessionUserId: session?.user?.id,
guestToken: parsed.data.guestToken,
});
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
throw error;
+4 -1
View File
@@ -140,7 +140,10 @@ export async function startRebookAction(input: unknown): Promise<void> {
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, {
sessionUserId: session?.user?.id,
guestToken: parsed.data.guestToken,
});
} catch (error) {
if (error instanceof PermissionDeniedError) {
redirect("/appointments");
+37 -10
View File
@@ -1,3 +1,6 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { validateGuestAccessToken } from "@/services/scheduling/guest-access.service";
/**
* Customer-facing ownership checks. Kept in the feature/action layer, not
* the domain services — AppointmentService/BookingService are also used
@@ -17,31 +20,55 @@ export class PermissionDeniedError extends Error {
}
export interface OwnableAppointment {
id: string;
customer: { userId: string | null };
}
export interface AppointmentAccess {
sessionUserId?: string | undefined;
/** Raw token from the guest's booking-confirmation link — see guest-access.service.ts. Ignored for a registered customer's appointment. */
guestToken?: string;
}
/**
* Ownership rule:
* - A REGISTERED customer's appointment (customer.userId is set) always
* requires the requesting session to match that exact user. An
* appointment id alone is never sufficient — see the Phase 4 brief's
* explicit "Appointment IDs are not treated as permission".
* - A GUEST appointment (customer.userId is null) has no stronger
* credential available in this phase — no magic-link/OTP system was
* built (explicitly deferred, see docs/phase-4-summary.md). Access is
* by the appointment's own unguessable id, the same "order tracking"
* pattern many guest-checkout systems use. This is a deliberate,
* documented scope limitation, not an oversight.
* - A GUEST appointment (customer.userId is null) requires the current,
* unexpired, unrevoked access token for THIS exact appointment (see
* GuestAppointmentAccessToken in schema.prisma and
* guest-access.service.ts). Closes the Phase 4 gap where the
* appointment's own id — unguessable but never actually secret — was
* the only credential (see docs/phase-4-summary.md and
* docs/phase-7-summary.md).
*/
export function assertCanAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): void {
if (appointment.customer.userId && appointment.customer.userId !== sessionUserId) {
export async function assertCanAccessAppointment(
prisma: PrismaClient,
appointment: OwnableAppointment,
access: AppointmentAccess,
): Promise<void> {
if (appointment.customer.userId) {
if (appointment.customer.userId !== access.sessionUserId) throw new PermissionDeniedError();
return;
}
if (
!access.guestToken ||
!(await validateGuestAccessToken(prisma, { appointmentId: appointment.id, rawToken: access.guestToken }))
) {
throw new PermissionDeniedError();
}
}
export function canAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): boolean {
export async function canAccessAppointment(
prisma: PrismaClient,
appointment: OwnableAppointment,
access: AppointmentAccess,
): Promise<boolean> {
try {
assertCanAccessAppointment(appointment, sessionUserId);
await assertCanAccessAppointment(prisma, appointment, access);
return true;
} catch {
return false;
@@ -4,7 +4,15 @@ import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { startRebookAction } from "../actions/draft";
export function BookAgainButton({ appointmentId, className }: { appointmentId: string; className?: string }) {
export function BookAgainButton({
appointmentId,
guestToken,
className,
}: {
appointmentId: string;
guestToken?: string;
className?: string;
}) {
const [isPending, startTransition] = useTransition();
return (
@@ -13,7 +21,7 @@ export function BookAgainButton({ appointmentId, className }: { appointmentId: s
size="sm"
className={className}
isLoading={isPending}
onClick={() => startTransition(() => startRebookAction({ appointmentId }))}
onClick={() => startTransition(() => startRebookAction({ appointmentId, guestToken }))}
>
Book Again
</Button>
@@ -7,7 +7,13 @@ import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/textarea";
import { cancelCustomerAppointmentAction } from "../actions/customer-appointments";
export function CancelAppointmentDialog({ appointmentId }: { appointmentId: string }) {
export function CancelAppointmentDialog({
appointmentId,
guestToken,
}: {
appointmentId: string;
guestToken?: string;
}) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -21,6 +27,7 @@ export function CancelAppointmentDialog({ appointmentId }: { appointmentId: stri
const res = await cancelCustomerAppointmentAction({
appointmentId,
reason: reason || undefined,
guestToken,
});
if (!res.success) {
setError(res.error ?? "Something went wrong. Please try again.");
+3
View File
@@ -85,15 +85,18 @@ export const saveDetailsStepSchema = z.object({
export const startRebookSchema = z.object({
appointmentId: z.string(),
guestToken: z.string().optional(),
});
export const customerCancelSchema = z.object({
appointmentId: z.string(),
reason: z.string().max(500).optional(),
guestToken: z.string().optional(),
});
export const customerRescheduleSchema = z.object({
appointmentId: z.string(),
staffId: z.string().optional(),
startAt: z.iso.datetime(),
guestToken: z.string().optional(),
});
@@ -0,0 +1,41 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { markAllNotificationsRead, markNotificationRead } from "@/services/notifications/notification.service";
export interface MarkReadActionResult {
success: boolean;
error?: string;
}
/**
* Shared across all three notification-centre pages (customer/staff/owner)
* — ownership is enforced by recipientUserId matching the session, exactly
* the same rule regardless of which role is asking (see
* NotificationService.markNotificationRead's own scoped WHERE clause), so
* one action file covers all of them rather than three near-identical
* copies.
*/
export async function markNotificationReadAction(notificationId: string): Promise<MarkReadActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
await markNotificationRead(prisma, { notificationId, userId: session.user.id });
revalidatePath("/notifications");
revalidatePath("/staff/notifications");
revalidatePath("/owner/notifications");
return { success: true };
}
export async function markAllNotificationsReadAction(): Promise<MarkReadActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
await markAllNotificationsRead(prisma, session.user.id);
revalidatePath("/notifications");
revalidatePath("/staff/notifications");
revalidatePath("/owner/notifications");
return { success: true };
}
@@ -0,0 +1,35 @@
"use server";
import { auth } from "@/auth";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { setNotificationPreference } from "@/services/notifications/notification.service";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
export interface PreferencesActionResult {
success: boolean;
error?: string;
}
/**
* The customer-side counterpart to features/staff/actions/profile.ts's
* saveNotificationPreferencesAction — same shape, but scoped to the
* session's own customer account and the one preference-controlled
* customer-facing type (BOOKING_REMINDER; see notifications/types.ts).
*/
export async function saveCustomerNotificationPreferencesAction(
cells: PreferenceCellState[],
): Promise<PreferencesActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
for (const cell of cells) {
await setNotificationPreference(prisma, {
userId: session.user.id,
type: NotificationType.BOOKING_REMINDER,
channel: cell.channel,
isEnabled: cell.isEnabled,
});
}
return { success: true };
}
@@ -0,0 +1,51 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { subscribeToPush, unsubscribeFromPush } from "@/services/notifications/push-subscription.service";
export interface PushActionResult {
success: boolean;
error?: string;
}
/**
* Registered users only (staff, owner, signed-in customer) — a guest has
* no session to attribute the subscription to. A guest subscribing right
* after a guest booking is a real, documented gap for a future pass (see
* docs/phase-7-summary.md, "Known limitations"), not an oversight.
*/
export async function subscribeToPushAction(input: {
endpoint: string;
p256dhKey: string;
authKey: string;
deviceLabel?: string;
}): Promise<PushActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
const [business, customerProfile] = await Promise.all([
getPrimaryBusiness(),
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
]);
await subscribeToPush(prisma, {
businessId: business.id,
userId: session.user.id,
customerId: customerProfile?.id,
endpoint: input.endpoint,
p256dhKey: input.p256dhKey,
authKey: input.authKey,
deviceLabel: input.deviceLabel,
});
return { success: true };
}
export async function unsubscribeFromPushAction(endpoint: string): Promise<PushActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
await unsubscribeFromPush(prisma, { endpoint, ownerUserId: session.user.id });
return { success: true };
}
@@ -0,0 +1,27 @@
import Link from "next/link";
import { Bell } from "lucide-react";
/**
* Fixed top-right on every screen inside the shell that renders it (see
* StaffShell and (customer)/layout.tsx) — unread count is fetched
* server-side by the layout and passed in, not polled client-side, so it
* only ever reflects "as of this page load/navigation". Real-time badge
* updates without a reload would need a persistent connection this phase
* doesn't build (see docs/phase-7-summary.md, "Known limitations").
*/
export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
return (
<Link
href={href}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
className="fixed top-4 right-4 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-line bg-white shadow-sm"
>
<Bell className="h-4 w-4" aria-hidden="true" />
{unreadCount > 0 && (
<span className="bg-danger absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Link>
);
}
@@ -0,0 +1,112 @@
"use client";
import { Bell, BellOff } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { markAllNotificationsReadAction, markNotificationReadAction } from "../actions/mark-read";
export interface NotificationListItem {
id: string;
subject: string | null;
bodyText: string;
deepLinkPath: string | null;
readAt: Date | null;
createdAt: Date;
}
function formatTimestamp(date: Date): string {
return date.toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
/**
* Shared by /notifications, /staff/notifications, /owner/notifications —
* ownership scoping already happened server-side (each page only ever
* passes that session's own notifications, via
* NotificationService.listInAppNotifications), so this component just
* renders whatever list it's given and reports read/unread actions back.
*/
export function NotificationList({ notifications }: { notifications: NotificationListItem[] }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const hasUnread = notifications.some((n) => !n.readAt);
const handleOpen = (notification: NotificationListItem) => {
if (!notification.readAt) {
startTransition(async () => {
await markNotificationReadAction(notification.id);
router.refresh();
});
}
};
const handleMarkAllRead = () => {
startTransition(async () => {
await markAllNotificationsReadAction();
router.refresh();
});
};
if (notifications.length === 0) {
return (
<EmptyState
icon={BellOff}
title="No notifications yet"
description="You'll see booking updates and reminders here."
/>
);
}
return (
<div className="space-y-4">
{hasUnread && (
<div className="flex justify-end">
<Button variant="secondary" size="sm" isLoading={isPending} onClick={handleMarkAllRead}>
Mark All Read
</Button>
</div>
)}
<ul className="space-y-2">
{notifications.map((notification) => {
const unread = !notification.readAt;
const content = (
<div
className={`rounded-jumbo border p-4 transition-colors ${
unread ? "border-accent/30 bg-accent/5" : "border-line bg-white"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<Bell className={`mt-0.5 h-4 w-4 shrink-0 ${unread ? "text-accent" : "text-muted"}`} aria-hidden="true" />
<div>
{notification.subject && <p className="text-sm font-bold">{notification.subject}</p>}
<p className="text-muted mt-0.5 text-sm">{notification.bodyText}</p>
<p className="text-muted mt-1 text-xs">{formatTimestamp(notification.createdAt)}</p>
</div>
</div>
{unread && <span role="status" className="bg-accent mt-1 h-2 w-2 shrink-0 rounded-full" aria-label="Unread" />}
</div>
</div>
);
return (
<li key={notification.id}>
{notification.deepLinkPath ? (
<Link href={notification.deepLinkPath} onClick={() => handleOpen(notification)} className="block">
{content}
</Link>
) : (
<button type="button" onClick={() => handleOpen(notification)} className="block w-full text-left">
{content}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { subscribeToPushAction, unsubscribeFromPushAction } from "../actions/push-subscriptions";
import { urlBase64ToUint8Array } from "../lib/vapid";
type Status = "checking" | "unsupported" | "subscribed" | "not-subscribed" | "denied";
/**
* The one control that ever asks for push permission — deliberately not
* rendered anywhere that loads automatically on first visit (see the
* Phase 7 brief's "never request permission on first load"). Placed in
* Settings, a screen someone only reaches by deliberately navigating
* there, with the benefit explained in the paragraph above the button
* before anything is requested.
*/
function isPushSupported(): boolean {
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window;
}
export function PushSubscribeToggle() {
// Always starts as "checking" (renders nothing) on both server and
// client — `isPushSupported`/`Notification.permission` are
// client-only reads, so resolving them in a useState initializer would
// make the client's hydration render disagree with the server's and
// trigger a hydration error. The real status is only ever resolved
// client-side, post-mount, in the effect below.
const [status, setStatus] = useState<Status>("checking");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (status !== "checking") return;
let cancelled = false;
Promise.resolve()
.then(async () => {
if (!isPushSupported()) return "unsupported" as const;
if (Notification.permission === "denied") return "denied" as const;
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
return sub ? ("subscribed" as const) : ("not-subscribed" as const);
})
.then((resolved) => {
if (!cancelled) setStatus(resolved);
})
.catch(() => {
if (!cancelled) setStatus("not-subscribed");
});
return () => {
cancelled = true;
};
}, [status]);
const subscribe = () => {
setError(null);
startTransition(async () => {
try {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
setError("Push notifications aren't configured for this business yet.");
return;
}
const registration = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setStatus("denied");
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
const json = subscription.toJSON();
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
setError("Couldn't complete the push subscription. Please try again.");
return;
}
const result = await subscribeToPushAction({
endpoint: json.endpoint,
p256dhKey: json.keys.p256dh,
authKey: json.keys.auth,
deviceLabel: navigator.userAgent.slice(0, 100),
});
if (!result.success) {
setError(result.error ?? "Couldn't save your subscription.");
return;
}
setStatus("subscribed");
} catch {
setError("Couldn't enable push notifications. Please try again.");
}
});
};
const unsubscribe = () => {
setError(null);
startTransition(async () => {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await unsubscribeFromPushAction(subscription.endpoint);
await subscription.unsubscribe();
}
setStatus("not-subscribed");
});
};
if (status === "checking") return null;
if (status === "unsupported") {
return <p className="text-muted text-sm">Push notifications aren&apos;t supported in this browser.</p>;
}
if (status === "denied") {
return (
<p className="text-muted text-sm">
Push notifications are blocked for this site in your browser settings. Allow notifications for this site to
turn them back on.
</p>
);
}
return (
<div className="space-y-2">
{status === "not-subscribed" && (
<p className="text-muted text-sm">
Get a push notification on this device for reminders and important updates, in addition to email.
</p>
)}
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button
variant={status === "subscribed" ? "secondary" : "primary"}
size="sm"
isLoading={isPending}
onClick={status === "subscribed" ? unsubscribe : subscribe}
>
{status === "subscribed" ? "Disable Push Notifications" : "Enable Push Notifications"}
</Button>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/** Web Push wants the VAPID public key as a Uint8Array, not the base64url string the browser subscribe() call ultimately needs to sign with — standard boilerplate conversion, no library needed for it. */
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff } from "@/services/staff/staff.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Owner-on-behalf-of-staff breaks and time off (Phase 7, closing the Phase
* 6-documented gap — see docs/phase-7-summary.md, "Owner-managed
* availability"). The owner Server Actions in
* src/features/owner/actions/breaks.ts and time-off.ts are thin wrappers:
* `assertOwnerPermission` (already covered by
* src/features/owner/__tests__/authorization.integration.test.ts) plus a
* business-scoped staffId lookup, then these exact StaffService calls with
* an explicit target staffId instead of the caller's own. Importing the
* "use server" action files directly here isn't possible — they import
* `@/auth`, which pulls in next-auth's NextAuth() factory and fails
* outside Next's own bundler/runtime (`Cannot find module
* "next/server"`), so what these tests verify is the StaffService plumbing
* an owner action delegates to, exactly as every other StaffService
* integration test in this suite does.
*/
describe("Owner-managed breaks and time off (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("creates and deletes a break against an explicit target staffId, not the caller's own", async () => {
const { staffId } = await createTestStaff(business);
const created = await createBreak(prisma, {
staffId,
businessTimezone: business.timezone,
input: { label: "Owner-added break", startMinutes: 12 * 60, endMinutes: 12 * 60 + 30, days: [Weekday.MONDAY] },
});
expect(created.success).toBe(true);
if (!created.success) return;
const stored = await prisma.break.findUnique({ where: { id: created.break.id } });
expect(stored?.staffId).toBe(staffId);
expect(stored?.label).toBe("Owner-added break");
const removed = await deleteBreak(prisma, { breakId: created.break.id, staffId });
expect(removed.success).toBe(true);
const gone = await prisma.break.findUnique({ where: { id: created.break.id } });
expect(gone).toBeNull();
});
it("creates and deletes whole-day time off against an explicit target staffId", async () => {
const { staffId } = await createTestStaff(business);
const created = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: "2027-02-01", endDate: "2027-02-03", reason: "Owner-added leave" },
});
expect(created.success).toBe(true);
if (!created.success) return;
const stored = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
expect(stored?.staffId).toBe(staffId);
expect(stored?.reason).toBe("Owner-added leave");
const removed = await deleteTimeOff(prisma, { timeOffId: created.timeOff.id, staffId });
expect(removed.success).toBe(true);
const gone = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
expect(gone).toBeNull();
});
it("a break created for staff A is never visible when querying by staff B's id (no accidental cross-staff writes)", async () => {
const { staffId: staffA } = await createTestStaff(business);
const { staffId: staffB } = await createTestStaff(business);
await createBreak(prisma, {
staffId: staffA,
businessTimezone: business.timezone,
input: { label: "A's break", startMinutes: 9 * 60, endMinutes: 9 * 60 + 15, days: [Weekday.TUESDAY] },
});
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
expect(staffBBreaks).toHaveLength(0);
});
});
+13 -6
View File
@@ -15,6 +15,7 @@ import {
start,
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
@@ -74,9 +75,11 @@ export async function ownerConfirmAppointmentAction(appointmentId: string): Prom
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) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingApproved(prisma, result.appointment);
return { success: true };
}
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
@@ -107,6 +110,7 @@ export async function ownerCompleteAppointmentAction(appointmentId: string): Pro
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingCompleted(prisma, result.appointment);
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
@@ -117,9 +121,11 @@ export async function ownerMarkNoShowAction(appointmentId: string): Promise<Staf
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) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyAppointmentNoShow(prisma, result.appointment);
return { success: true };
}
export async function ownerCancelAppointmentAction(params: {
@@ -172,6 +178,7 @@ export async function ownerRescheduleAppointmentAction(params: {
entityId: params.appointmentId,
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
});
await notifyBookingBarberChanged(prisma, result.appointment);
}
return { success: true };
@@ -0,0 +1,26 @@
import { ownerCreateBreakAction, ownerDeleteBreakAction, ownerUpdateBreakAction } from "./breaks";
import type { BreakActionSet } from "@/features/staff/components/break-form-sheet";
/**
* Plain module (no "use server") assembling the owner break Server Actions
* into the `BreakActionSet` shape BreaksList/BreakFormSheet expect, bound
* to one target staff member.
*
* Uses `.bind(null, staffId)`, not an arrow-function wrapper — a Client
* Component can only receive an actual Server Action reference as a prop;
* React's Flight serializer specifically recognizes `.bind()` on a Server
* Action (Next's own documented pattern for passing extra arguments) and
* reconstructs the call server-side, but a plain closure like `(input) =>
* ownerCreateBreakAction(staffId, input)` is just an ordinary function with
* no such registration and throws "Functions cannot be passed directly to
* Client Components" at render time — found empirically in Phase 7 (see
* docs/phase-7-summary.md) when this file's first version used arrow
* wrappers instead.
*/
export function createOwnerBreakActionSet(staffId: string): BreakActionSet {
return {
create: ownerCreateBreakAction.bind(null, staffId),
update: ownerUpdateBreakAction.bind(null, staffId),
remove: ownerDeleteBreakAction.bind(null, staffId),
};
}
+155
View File
@@ -0,0 +1,155 @@
"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 { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
import type { BreakInput } from "@/services/staff/staff.service";
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
export interface BreakConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface BreakActionResult {
success: boolean;
error?: string;
conflicts?: BreakConflictItem[];
}
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
return 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(),
}));
}
async function requireStaffManagerFor(staffId: string) {
const session = await auth();
if (!session?.user) return { owner: 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 { owner: null, error: error.message } as const;
throw error;
}
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
});
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
return { owner, target, error: null } as const;
}
/**
* Owner-on-behalf-of-staff break management — same StaffService calls the
* barber's own editor uses, targeting an explicit `staffId` argument
* instead of the caller's own. Assembled into BreaksList/BreakFormSheet's
* `actions` prop shape by the plain (non-"use server") factory in
* break-action-set.ts — a "use server" file may only export async
* functions, never a plain object or a function that returns one
* synchronously, so the per-staffId binding has to live outside this file
* (see docs/phase-6-summary.md for where this constraint was first found).
*/
export async function ownerCreateBreakAction(
staffId: string,
input: BreakInput,
acknowledgeConflicts?: boolean,
): Promise<BreakActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await createBreak(prisma, {
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_BREAK_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your breaks",
viewSchedulePath: "/staff/settings/breaks",
});
return { success: true };
}
export async function ownerUpdateBreakAction(
staffId: string,
breakId: string,
input: BreakInput,
acknowledgeConflicts?: boolean,
): Promise<BreakActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await updateBreak(prisma, {
breakId,
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_BREAK_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your breaks",
viewSchedulePath: "/staff/settings/breaks",
});
return { success: true };
}
export async function ownerDeleteBreakAction(staffId: string, breakId: string): Promise<BreakActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await deleteBreak(prisma, { breakId, staffId });
if (!result.success) return { success: false, error: "Couldn't delete this break." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_BREAK_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your breaks",
viewSchedulePath: "/staff/settings/breaks",
});
return { success: true };
}
+45 -4
View File
@@ -15,6 +15,7 @@ import {
} from "@/services/staff/staff.service";
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
async function requireStaffManager() {
const session = await auth();
@@ -27,7 +28,10 @@ async function requireStaffManager() {
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const staff = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { timezone: true } }, user: { select: { name: true } } },
});
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
@@ -182,15 +186,31 @@ export async function setStaffServiceEligibilityAction(
return { success: true };
}
export interface WorkingHoursConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface WorkingHoursActionResult {
success: boolean;
error?: string;
conflicts?: WorkingHoursConflictItem[];
}
/** 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). */
/**
* 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. Now conflict-reviewed like every other
* owner mutation that can strand an appointment (see
* docs/phase-7-summary.md, "Working-hours conflict review") — this closes
* the gap Phase 6 documented and deliberately left open.
*/
export async function updateStaffWorkingHoursAction(
staffId: string,
days: WorkingHoursDayInput[],
acknowledgeConflicts = false,
): Promise<WorkingHoursActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
@@ -198,9 +218,23 @@ export async function updateStaffWorkingHoursAction(
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 });
const result = await setWorkingHours(prisma, {
staffId,
businessTimezone: owner.business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't save working 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(),
})),
};
}
await writeAuditLog(prisma, {
@@ -209,6 +243,13 @@ export async function updateStaffWorkingHoursAction(
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your working hours",
viewSchedulePath: "/staff/settings/working-hours",
});
return { success: true };
}
@@ -0,0 +1,17 @@
import { ownerCreateTimeOffAction, ownerDeleteTimeOffAction, ownerUpdateTimeOffAction } from "./time-off";
import type { TimeOffActionSet } from "@/features/staff/components/time-off-form-sheet";
/**
* Plain module (no "use server") assembling the owner time-off Server
* Actions into the `TimeOffActionSet` shape TimeOffList/TimeOffFormSheet
* expect, bound to one target staff member — see break-action-set.ts for
* why `.bind(null, staffId)` is required here instead of an arrow-function
* wrapper.
*/
export function createOwnerTimeOffActionSet(staffId: string): TimeOffActionSet {
return {
create: ownerCreateTimeOffAction.bind(null, staffId),
update: ownerUpdateTimeOffAction.bind(null, staffId),
remove: ownerDeleteTimeOffAction.bind(null, staffId),
};
}
+146
View File
@@ -0,0 +1,146 @@
"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 { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
import type { TimeOffInput } from "@/services/staff/staff.service";
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
export interface TimeOffConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface TimeOffActionResult {
success: boolean;
error?: string;
conflicts?: TimeOffConflictItem[];
}
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
return 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(),
}));
}
async function requireStaffManagerFor(staffId: string) {
const session = await auth();
if (!session?.user) return { owner: null, target: 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 { owner: null, target: null, error: error.message } as const;
throw error;
}
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
});
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
return { owner, target, error: null } as const;
}
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
export async function ownerCreateTimeOffAction(
staffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await createTimeOff(prisma, {
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_TIME_OFF_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your time off",
viewSchedulePath: "/staff/settings/time-off",
});
return { success: true };
}
export async function ownerUpdateTimeOffAction(
staffId: string,
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await updateTimeOff(prisma, {
timeOffId,
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_TIME_OFF_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your time off",
viewSchedulePath: "/staff/settings/time-off",
});
return { success: true };
}
export async function ownerDeleteTimeOffAction(staffId: string, timeOffId: string): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await deleteTimeOff(prisma, { timeOffId, staffId });
if (!result.success) return { success: false, error: "Couldn't remove this time off." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_TIME_OFF_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your time off",
viewSchedulePath: "/staff/settings/time-off",
});
return { success: true };
}
@@ -158,7 +158,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
</p>
)}
@@ -193,7 +193,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved — changes are live for customers immediately.
</p>
)}
@@ -158,7 +158,12 @@ export function LoyaltyConfigForm({
<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="Apply reward automatically"
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
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."
@@ -203,7 +208,7 @@ export function LoyaltyConfigForm({
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -189,7 +189,7 @@ export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningH
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved. The booking engine reflects these hours immediately.
</p>
)}
@@ -162,7 +162,7 @@ export function StaffDetailPanel({
</p>
)}
{roleSaved && !roleError && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -206,7 +206,7 @@ export function StaffDetailPanel({
</p>
)}
{servicesSaved && !servicesError && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -220,11 +220,19 @@ export function StaffDetailPanel({
<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">
<div className="rounded-jumbo border-line divide-line divide-y 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>
<Link href={`/owner/team/${staffId}/breaks`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Breaks</span>
<span className="text-muted text-xs">Edit</span>
</Link>
<Link href={`/owner/team/${staffId}/time-off`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Time Off</span>
<span className="text-muted text-xs">Edit</span>
</Link>
</div>
</section>
@@ -0,0 +1,89 @@
"use client";
import { Download } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari's own (non-standard, but the only signal it exposes).
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isIos(): boolean {
if (typeof navigator === "undefined") return false;
return /iphone|ipad|ipod/i.test(navigator.userAgent);
}
/**
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
* service worker criteria are met — we capture and defer it (the spec
* requires preventDefault() to control exactly when the native prompt
* shows) and trigger it from this button instead of an unsolicited popup.
* iOS Safari never fires that event at all, so it gets manual
* "Add to Home Screen" instructions instead — there's no programmatic
* install API on that platform.
*/
export function InstallAppButton() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(() => isStandalone());
const [showIosInstructions, setShowIosInstructions] = useState(false);
useEffect(() => {
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
if (installed) {
return <p className="text-muted text-sm">NextChair is installed on this device.</p>;
}
if (deferredPrompt) {
return (
<Button
size="sm"
onClick={async () => {
await deferredPrompt.prompt();
await deferredPrompt.userChoice;
setDeferredPrompt(null);
}}
>
<Download className="h-4 w-4" aria-hidden="true" /> Install App
</Button>
);
}
if (isIos()) {
return showIosInstructions ? (
<p className="text-muted text-sm">
Tap the Share icon in Safari, then choose &ldquo;Add to Home Screen&rdquo;.
</p>
) : (
<Button size="sm" variant="secondary" onClick={() => setShowIosInstructions(true)}>
<Download className="h-4 w-4" aria-hidden="true" /> Install App
</Button>
);
}
return <p className="text-muted text-sm">Installing isn&apos;t available in this browser yet.</p>;
}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState } from "react";
/**
* Registers the service worker once, app-wide, and surfaces the
* update-available handshake public/sw.js sets up: a new SW version
* installs and sits `waiting` rather than taking over immediately, so an
* open tab isn't silently switched to a new app version mid-session. This
* component detects that waiting worker and lets the user opt into the
* refresh instead.
*/
export function ServiceWorkerManager() {
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
// An update was already downloaded (e.g. by another tab) before this page loaded.
if (registration.waiting && navigator.serviceWorker.controller) {
setWaitingWorker(registration.waiting);
}
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener("statechange", () => {
// Only a real update — an installing worker with no existing
// controller is just this tab's first-ever SW install, not an update.
if (installing.state === "installed" && navigator.serviceWorker.controller) {
setWaitingWorker(installing);
}
});
});
})
.catch(() => {
// Best-effort — offline/install/update support degrades gracefully without it.
});
let reloaded = false;
const onControllerChange = () => {
if (reloaded) return;
reloaded = true;
window.location.reload();
};
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
return () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
}, []);
if (!waitingWorker || dismissed) return null;
return (
<div
role="status"
className="rounded-jumbo bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
>
<span>A new version is available.</span>
<div className="flex items-center gap-3">
<button
type="button"
className="font-bold whitespace-nowrap underline"
onClick={() => waitingWorker.postMessage({ type: "SKIP_WAITING" })}
>
Refresh
</button>
<button
type="button"
aria-label="Dismiss"
className="text-white/60"
onClick={() => setDismissed(true)}
>
✕
</button>
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import {
start,
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
/**
@@ -58,9 +59,11 @@ export async function confirmAppointmentAction(
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) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingApproved(prisma, result.appointment);
return { success: true };
}
export async function checkInAppointmentAction(
@@ -97,6 +100,7 @@ export async function completeAppointmentAction(
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingCompleted(prisma, result.appointment);
const loyaltyResult = await awardStampForCompletedAppointment(prisma, {
appointmentId,
@@ -115,9 +119,11 @@ export async function markNoShowAction(
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) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyAppointmentNoShow(prisma, result.appointment);
return { success: true };
}
export async function cancelStaffAppointmentAction(params: {
+13 -4
View File
@@ -1,9 +1,11 @@
"use server";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { setNotificationPreferences, updateStaffProfile } from "@/services/staff/staff.service";
import type { NotificationPreferenceCell } from "@/services/staff/staff.service";
import { setNotificationPreference } from "@/services/notifications/notification.service";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { updateStaffProfile } from "@/services/staff/staff.service";
export interface ProfileActionResult {
success: boolean;
@@ -35,11 +37,18 @@ export async function updateProfileAction(input: {
}
export async function saveNotificationPreferencesAction(
cells: NotificationPreferenceCell[],
cells: PreferenceCellState[],
): Promise<ProfileActionResult> {
const staff = await getActingStaff();
if (!staff) return { success: false, error: "You must be signed in as staff." };
await setNotificationPreferences(prisma, staff.userId, cells);
for (const cell of cells) {
await setNotificationPreference(prisma, {
userId: staff.userId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
channel: cell.channel,
isEnabled: cell.isEnabled,
});
}
return { success: true };
}
+1 -1
View File
@@ -174,7 +174,7 @@ export async function confirmStaffBookingAction(notes?: string): Promise<StaffBo
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
await writeLastBookingReference(result.appointment.id);
await writeLastBookingReference({ appointmentId: result.appointment.id });
await clearStaffDraft();
redirect(`/staff/appointments/${result.appointment.id}`);
}
+28 -3
View File
@@ -5,18 +5,43 @@ import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { setWorkingHours } from "@/services/staff/staff.service";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
export interface WorkingHoursConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface WorkingHoursActionResult {
success: boolean;
error?: string;
conflicts?: WorkingHoursConflictItem[];
}
export async function saveWorkingHoursAction(days: WorkingHoursDayInput[]): Promise<WorkingHoursActionResult> {
export async function saveWorkingHoursAction(
days: WorkingHoursDayInput[],
acknowledgeConflicts = false,
): Promise<WorkingHoursActionResult> {
const staff = await getActingStaff();
if (!staff) return { success: false, error: "You must be signed in as staff." };
const result = await setWorkingHours(prisma, { staffId: staff.id, days });
const result = await setWorkingHours(prisma, {
staffId: staff.id,
businessTimezone: staff.business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't save working 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(),
})),
};
}
return { success: true };
}
@@ -9,6 +9,20 @@ import { Input } from "@/components/ui/input";
import { Weekday } from "@/generated/prisma/enums";
import type { BreakActionResult, BreakConflictItem } from "../actions/breaks";
import { createBreakAction, deleteBreakAction, updateBreakAction } from "../actions/breaks";
import type { BreakInput } from "@/services/staff/staff.service";
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface BreakActionSet {
create: (input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
update: (breakId: string, input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
remove: (breakId: string) => Promise<BreakActionResult>;
}
const DEFAULT_ACTIONS: BreakActionSet = {
create: createBreakAction,
update: updateBreakAction,
remove: deleteBreakAction,
};
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
@@ -53,10 +67,14 @@ export function BreakFormSheet({
open,
onClose,
existing,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableBreak | null;
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [label, setLabel] = useState(existing?.label ?? "Break");
@@ -105,8 +123,8 @@ export function BreakFormSheet({
startTransition(async () => {
const input = { label, startMinutes, endMinutes, days };
const result: BreakActionResult = existing
? await updateBreakAction(existing.id, input, acknowledgeConflicts)
: await createBreakAction(input, acknowledgeConflicts);
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
@@ -120,7 +138,7 @@ export function BreakFormSheet({
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await deleteBreakAction(existing.id);
const result = await actions.remove(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't delete this break.");
return;
@@ -142,7 +160,7 @@ export function BreakFormSheet({
<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={`/staff/appointments/${c.id}`} className="font-bold underline">
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
+12 -2
View File
@@ -5,7 +5,7 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Weekday } from "@/generated/prisma/enums";
import { BreakFormSheet } from "./break-form-sheet";
import type { EditableBreak } from "./break-form-sheet";
import type { BreakActionSet, EditableBreak } from "./break-form-sheet";
const DAY_SHORT: Record<Weekday, string> = {
MONDAY: "Mon",
@@ -40,7 +40,15 @@ function toTimeString(minutes: number): string {
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
export function BreaksList({
breaks,
actions,
appointmentHrefBase,
}: {
breaks: EditableBreak[];
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableBreak | null>(null);
@@ -90,6 +98,8 @@ export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
@@ -3,59 +3,48 @@
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { NotificationCategory, NotificationChannel } from "@/generated/prisma/enums";
import { saveNotificationPreferencesAction } from "../actions/profile";
import { NotificationChannel } from "@/generated/prisma/enums";
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
BOOKING_CONFIRMATION: "New booking confirmed",
BOOKING_REMINDER: "Upcoming appointment reminders",
BOOKING_CHANGED: "Appointment rescheduled",
BOOKING_CANCELLED: "Appointment cancelled",
LOYALTY_REWARD_EARNED: "Customer earns a loyalty stamp",
LOYALTY_REWARD_AVAILABLE: "Customer reward becomes available",
MARKETING: "Product news and tips",
};
const CATEGORY_ORDER: NotificationCategory[] = [
NotificationCategory.BOOKING_CONFIRMATION,
NotificationCategory.BOOKING_REMINDER,
NotificationCategory.BOOKING_CHANGED,
NotificationCategory.BOOKING_CANCELLED,
NotificationCategory.LOYALTY_REWARD_EARNED,
NotificationCategory.LOYALTY_REWARD_AVAILABLE,
NotificationCategory.MARKETING,
];
const CHANNEL_ORDER: NotificationChannel[] = [
NotificationChannel.EMAIL,
NotificationChannel.SMS,
NotificationChannel.PUSH,
];
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", SMS: "SMS", PUSH: "Push" };
const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
export interface PreferenceCellState {
category: NotificationCategory;
channel: NotificationChannel;
isEnabled: boolean;
}
export function NotificationPreferencesForm({ initial }: { initial: PreferenceCellState[] }) {
/**
* Every other notification a barber gets (new appointment, reschedule,
* cancellation, schedule change) is required — always sent, no toggle —
* so this form only ever shows the one type that's genuinely optional:
* appointment reminders. See NotificationService/types.ts,
* PREFERENCE_CONTROLLED_TYPES, and docs/phase-7-summary.md — showing a
* toggle for a required type would just be a no-op switch that does
* nothing when flipped, which the Phase 7 brief explicitly calls out to
* avoid.
*/
export interface NotificationPreferencesFormProps {
initial: PreferenceCellState[];
/** Recipient-specific — see features/staff/actions/profile.ts (staff) and features/notifications/actions/preferences.ts (customer). */
action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>;
}
export function NotificationPreferencesForm({ initial, action }: NotificationPreferencesFormProps) {
const router = useRouter();
const [cells, setCells] = useState(initial);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const isEnabled = (category: NotificationCategory, channel: NotificationChannel) =>
cells.find((c) => c.category === category && c.channel === channel)?.isEnabled ?? true;
const isEnabled = (channel: NotificationChannel) => cells.find((c) => c.channel === channel)?.isEnabled ?? true;
const toggle = (category: NotificationCategory, channel: NotificationChannel) => {
const toggle = (channel: NotificationChannel) => {
setSaved(false);
setCells((prev) =>
prev.map((c) => (c.category === category && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)),
);
setCells((prev) => prev.map((c) => (c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)));
};
const handleSave = () => {
startTransition(async () => {
await saveNotificationPreferencesAction(cells);
await action(cells);
setSaved(true);
router.refresh();
});
@@ -64,52 +53,30 @@ export function NotificationPreferencesForm({ initial }: { initial: PreferenceCe
return (
<div className="space-y-4">
<p className="text-muted text-sm">
Choose which of these you want to hear about, and how. This controls what you receive —
delivery isn&apos;t wired up yet.
Booking confirmations, changes, and cancellations always notify you — those aren&apos;t
optional. Choose how you&apos;d like to hear about upcoming appointment reminders.
</p>
<div className="overflow-hidden rounded-jumbo border border-line bg-white">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-line">
<th scope="col" className="px-4 py-3 font-bold">
Notification
</th>
{CHANNEL_ORDER.map((channel) => (
<th key={channel} scope="col" className="px-2 py-3 text-center text-xs font-bold">
{CHANNEL_LABEL[channel]}
</th>
))}
</tr>
</thead>
<tbody>
{CATEGORY_ORDER.map((category) => (
<tr key={category} className="border-b border-line last:border-b-0">
<th scope="row" className="px-4 py-3 text-xs font-medium">
{CATEGORY_LABEL[category]}
</th>
{CHANNEL_ORDER.map((channel) => (
<td key={channel} className="px-2 py-3 text-center">
<button
type="button"
role="switch"
aria-checked={isEnabled(category, channel)}
aria-label={`${CATEGORY_LABEL[category]} via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(category, channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(category, channel) ? "border-accent bg-accent" : "border-line bg-white"
}`}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
{CHANNEL_ORDER.map((channel) => (
<div key={channel} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
<button
type="button"
role="switch"
aria-checked={isEnabled(channel)}
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white"
}`}
/>
</div>
))}
</div>
{saved && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -79,7 +79,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -8,6 +8,7 @@ import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { TimeOffActionResult, TimeOffConflictItem } from "../actions/time-off";
import { createTimeOffAction, deleteTimeOffAction, updateTimeOffAction } from "../actions/time-off";
import type { TimeOffInput } from "@/services/staff/staff.service";
export interface EditableTimeOff {
id: string;
@@ -16,6 +17,26 @@ export interface EditableTimeOff {
reason: string | null;
}
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface TimeOffActionSet {
create: (
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
update: (
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
}
const DEFAULT_ACTIONS: TimeOffActionSet = {
create: createTimeOffAction,
update: updateTimeOffAction,
remove: deleteTimeOffAction,
};
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
@@ -24,10 +45,14 @@ export function TimeOffFormSheet({
open,
onClose,
existing,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableTimeOff | null;
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
@@ -60,8 +85,8 @@ export function TimeOffFormSheet({
startTransition(async () => {
const input = { startDate, endDate, reason: reason.trim() || undefined };
const result: TimeOffActionResult = existing
? await updateTimeOffAction(existing.id, input, acknowledgeConflicts)
: await createTimeOffAction(input, acknowledgeConflicts);
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
@@ -75,7 +100,7 @@ export function TimeOffFormSheet({
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await deleteTimeOffAction(existing.id);
const result = await actions.remove(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't remove this time off.");
return;
@@ -98,7 +123,7 @@ export function TimeOffFormSheet({
<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={`/staff/appointments/${c.id}`} className="font-bold underline">
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
@@ -3,7 +3,7 @@
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { EditableTimeOff } from "./time-off-form-sheet";
import type { EditableTimeOff, TimeOffActionSet } from "./time-off-form-sheet";
import { TimeOffFormSheet } from "./time-off-form-sheet";
function formatRange(startDate: string, endDate: string): string {
@@ -18,9 +18,13 @@ function formatRange(startDate: string, endDate: string): string {
export function TimeOffList({
upcoming,
past,
actions,
appointmentHrefBase,
}: {
upcoming: EditableTimeOff[];
past: EditableTimeOff[];
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableTimeOff | null>(null);
@@ -86,6 +90,8 @@ export function TimeOffList({
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
@@ -1,12 +1,13 @@
"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 { saveWorkingHoursAction } from "../actions/working-hours";
import type { WorkingHoursActionResult } from "../actions/working-hours";
import type { WorkingHoursActionResult, WorkingHoursConflictItem } from "../actions/working-hours";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
const DAY_ORDER: Weekday[] = [
@@ -58,11 +59,17 @@ export function WorkingHoursForm({
initialRows,
businessHoursLabel,
onSave = saveWorkingHoursAction,
appointmentHrefBase = "/staff/appointments",
}: {
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>;
onSave?: (
days: WorkingHoursDayInput[],
acknowledgeConflicts?: boolean,
) => Promise<WorkingHoursActionResult>;
/** Conflict links point at /owner/appointments/[id] when the owner is editing on a staff member's behalf. */
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
@@ -78,10 +85,12 @@ export function WorkingHoursForm({
);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [conflicts, setConflicts] = useState<WorkingHoursConflictItem[]>([]);
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)));
};
@@ -89,10 +98,11 @@ export function WorkingHoursForm({
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, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
};
const handleSave = () => {
const submit = (acknowledgeConflicts: boolean) => {
setError(null);
startTransition(async () => {
const result = await onSave(
@@ -101,49 +111,82 @@ export function WorkingHoursForm({
isOff: d.isOff,
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
})),
acknowledgeConflicts,
);
if (!result.success) {
setError(result.error ?? "Couldn't save working 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
working 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={`${appointmentHrefBase}/${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={() => 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-xs font-bold uppercase tracking-widest text-accent underline">
<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-y divide-line rounded-jumbo border border-line bg-white">
<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.isOff} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isOff: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} working`} />
<div>
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
<p className="text-[10px] text-muted">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
<p className="text-muted text-[10px]">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
</div>
</div>
{day.isOff ? (
<span className="text-xs text-muted">Off</span>
<span className="text-muted text-xs">Off</span>
) : (
<div className="flex items-center gap-2">
<input
type="time"
value={day.startTime}
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
className="rounded-lg border border-line px-2 py-1.5 text-xs"
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} start time`}
/>
<span className="text-xs text-muted">to</span>
<span className="text-muted text-xs">to</span>
<input
type="time"
value={day.endTime}
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
className="rounded-lg border border-line px-2 py-1.5 text-xs"
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} end time`}
/>
</div>
@@ -153,16 +196,16 @@ export function WorkingHoursForm({
</div>
{error && (
<p role="alert" className="text-sm font-medium text-danger">
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-sm font-medium text-accent">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Save
</Button>
</div>