"use server"; import { revalidatePath } from "next/cache"; import { auth } from "@/auth"; import { prisma } from "@/lib/db/prisma"; import { getAppointmentById } from "@/services/scheduling/appointment.service"; import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service"; import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization"; import { getFirstFriendlyMessage } from "../error-messages"; import { customerCancelSchema, customerRescheduleSchema } from "../schemas"; /** * Every mutation here re-checks ownership against the current session * before touching anything — never trusts that "the client only shows * this button for your own appointments" is actually true, since the * action can be called directly regardless of what the UI renders. */ export interface CustomerAppointmentActionResult { success: boolean; error?: string; wasLateCancellation?: boolean; } export async function cancelCustomerAppointmentAction(input: unknown): Promise { const parsed = customerCancelSchema.safeParse(input); if (!parsed.success) return { success: false, error: "Please try again." }; const appointment = await getAppointmentById(prisma, parsed.data.appointmentId); if (!appointment) return { success: false, error: "We couldn't find that appointment." }; const session = await auth(); try { 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; } const result = await cancelBooking(prisma, { appointmentId: parsed.data.appointmentId, cancelledByUserId: session?.user?.id, reason: parsed.data.reason, }); if (!result.success) { return { success: false, error: getFirstFriendlyMessage(result.validation) }; } // Deliberately not revalidating here (unlike reschedule) — every affected // page is already `dynamic = "force-dynamic"`, so revalidatePath buys no // extra freshness, and doing it immediately would drop the cancelled // appointment out of the Upcoming list mid-render, unmounting this very // dialog before the customer can read the late-cancellation notice or // click "Done". The dialog's own onClose already calls router.refresh(). return { success: true, wasLateCancellation: result.wasLateCancellation }; } export async function rescheduleCustomerAppointmentAction(input: unknown): Promise { const parsed = customerRescheduleSchema.safeParse(input); if (!parsed.success) return { success: false, error: "Please choose a valid time." }; const appointment = await getAppointmentById(prisma, parsed.data.appointmentId); if (!appointment) return { success: false, error: "We couldn't find that appointment." }; const session = await auth(); try { 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; } const result = await rescheduleBooking(prisma, { appointmentId: parsed.data.appointmentId, staffId: parsed.data.staffId, startAt: new Date(parsed.data.startAt), }); if (!result.success) { return { success: false, error: getFirstFriendlyMessage(result.validation) }; } revalidatePath("/appointments"); revalidatePath(`/appointments/${parsed.data.appointmentId}`); return { success: true }; }