Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 “Add to Home Screen”.
|
||||
</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'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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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't wired up yet.
|
||||
Booking confirmations, changes, and cancellations always notify you — those aren't
|
||||
optional. Choose how you'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'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>
|
||||
|
||||
Reference in new issue
Block a user