import type { PrismaClient } from "@/generated/prisma/client"; import { NotificationType } from "@/generated/prisma/enums"; import { notifySafely } from "@/services/notifications/notification.service"; import { getOwnerUserIds } from "@/services/notifications/recipients"; import type { Appointment } from "./booking.service"; import { scheduleAppointmentReminders } from "./reminder-scheduling"; /** * Builds and queues the Notification rows a booking-domain event * produces — called by booking.service.ts strictly AFTER the appointment * transaction has already committed, using notifySafely so a notification * failure can never roll back or fail an otherwise-successful booking. * Owns only "who gets told what, in what words" — the actual email/push * send happens later, out of process (see reminder-worker.ts). */ function formatDateLabel(date: Date, timezone: string): string { return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone }); } function formatTimeLabel(date: Date, timezone: string): string { return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }); } async function getBusinessTimezone(prisma: PrismaClient, businessId: string): Promise { const business = await prisma.business.findUnique({ where: { id: businessId }, select: { timezone: true } }); return business?.timezone ?? "Europe/London"; } function serviceNames(appointment: Appointment): string { return appointment.services.map((s) => s.service.name).join(", "); } export async function notifyBookingCreated( prisma: PrismaClient, appointment: Appointment, options: { guestAccessToken?: string } = {}, ): Promise { const timezone = await getBusinessTimezone(prisma, appointment.businessId); const dateLabel = formatDateLabel(appointment.startAt, timezone); const timeLabel = formatTimeLabel(appointment.startAt, timezone); const services = serviceNames(appointment); const isPending = appointment.status === "PENDING"; const customerDeepLink = `/appointments/${appointment.id}${options.guestAccessToken ? `?token=${options.guestAccessToken}` : ""}`; await notifySafely(prisma, { businessId: appointment.businessId, type: isPending ? NotificationType.BOOKING_PENDING_APPROVAL : NotificationType.BOOKING_CONFIRMED, recipientUserId: appointment.customer.userId ?? undefined, recipientCustomerId: appointment.customer.id, appointmentId: appointment.id, subject: isPending ? "Booking request received" : "Booking confirmed", bodyText: isPending ? `Your request for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is awaiting approval.` : `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is confirmed.`, deepLinkPath: customerDeepLink, idempotencyKey: `notify:booking-created:${appointment.id}`, }); await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.STAFF_NEW_APPOINTMENT, recipientUserId: appointment.staff.userId, appointmentId: appointment.id, subject: "New appointment", bodyText: `New appointment: ${services} with ${appointment.customer.firstName} ${appointment.customer.lastName} on ${dateLabel} at ${timeLabel}.`, deepLinkPath: `/staff/appointments/${appointment.id}`, idempotencyKey: `notify:staff-new-appointment:${appointment.id}`, }); const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId); for (const ownerUserId of ownerUserIds) { await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.OWNER_NEW_BOOKING, recipientUserId: ownerUserId, appointmentId: appointment.id, subject: "New booking", bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} booked ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel}.`, deepLinkPath: `/owner/appointments/${appointment.id}`, idempotencyKey: `notify:owner-new-booking:${appointment.id}:${ownerUserId}`, }); } } export async function notifyBookingRescheduled(prisma: PrismaClient, appointment: Appointment): Promise { const timezone = await getBusinessTimezone(prisma, appointment.businessId); const dateLabel = formatDateLabel(appointment.startAt, timezone); const timeLabel = formatTimeLabel(appointment.startAt, timezone); const services = serviceNames(appointment); // Included in the key so a second reschedule creates a fresh notification // rather than being deduped against the first one's key. const versionSuffix = appointment.startAt.getTime(); await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.BOOKING_RESCHEDULED, recipientUserId: appointment.customer.userId ?? undefined, recipientCustomerId: appointment.customer.id, appointmentId: appointment.id, subject: "Booking rescheduled", bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} has been moved to ${dateLabel} at ${timeLabel}.`, deepLinkPath: `/appointments/${appointment.id}`, idempotencyKey: `notify:booking-rescheduled:${appointment.id}:${versionSuffix}`, }); await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, recipientUserId: appointment.staff.userId, appointmentId: appointment.id, subject: "Appointment rescheduled", bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) has moved to ${dateLabel} at ${timeLabel}.`, deepLinkPath: `/staff/appointments/${appointment.id}`, idempotencyKey: `notify:staff-appointment-rescheduled:${appointment.id}:${versionSuffix}`, }); } export async function notifyBookingCancelled( prisma: PrismaClient, appointment: Appointment, options: { wasLateCancellation?: boolean; reason?: string } = {}, ): Promise { const timezone = await getBusinessTimezone(prisma, appointment.businessId); const dateLabel = formatDateLabel(appointment.startAt, timezone); const timeLabel = formatTimeLabel(appointment.startAt, timezone); const services = serviceNames(appointment); const lateNote = options.wasLateCancellation ? " This was a late cancellation." : ""; await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.BOOKING_CANCELLED, recipientUserId: appointment.customer.userId ?? undefined, recipientCustomerId: appointment.customer.id, appointmentId: appointment.id, subject: "Booking cancelled", bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} has been cancelled.${lateNote}`, deepLinkPath: `/appointments/${appointment.id}`, idempotencyKey: `notify:booking-cancelled:${appointment.id}`, }); await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.STAFF_APPOINTMENT_CANCELLED, recipientUserId: appointment.staff.userId, appointmentId: appointment.id, subject: "Appointment cancelled", bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) on ${dateLabel} at ${timeLabel} was cancelled.`, deepLinkPath: `/staff/appointments/${appointment.id}`, idempotencyKey: `notify:staff-appointment-cancelled:${appointment.id}`, }); const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId); for (const ownerUserId of ownerUserIds) { await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.OWNER_BOOKING_CANCELLED, recipientUserId: ownerUserId, appointmentId: appointment.id, subject: "Booking cancelled", bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} was cancelled.${lateNote}`, deepLinkPath: `/owner/appointments/${appointment.id}`, idempotencyKey: `notify:owner-booking-cancelled:${appointment.id}:${ownerUserId}`, }); } } /** A PENDING booking (requireApproval) is approved — see AppointmentService.confirm, only ever reachable from PENDING. */ export async function notifyBookingApproved(prisma: PrismaClient, appointment: Appointment): Promise { const timezone = await getBusinessTimezone(prisma, appointment.businessId); await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.BOOKING_APPROVED, recipientUserId: appointment.customer.userId ?? undefined, recipientCustomerId: appointment.customer.id, appointmentId: appointment.id, subject: "Booking approved", bodyText: `Your appointment for ${serviceNames(appointment)} with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)} has been approved.`, deepLinkPath: `/appointments/${appointment.id}`, idempotencyKey: `notify:booking-approved:${appointment.id}`, }); // Approval is the first point a PENDING (requireApproval) booking becomes // CONFIRMED — reminders were never scheduled at creation time for it (see // scheduleAppointmentReminders' own CONFIRMED-only guard), so this is // where that has to happen instead. await scheduleAppointmentReminders(prisma, appointment); } /** The owner reassigns an appointment to a different staff member — distinct from a plain time reschedule (see notifyBookingRescheduled, which still also fires for a reassignment since the underlying rescheduleBooking() call can't distinguish "why", see ownerRescheduleAppointmentAction). This tells the customer specifically who they're now booked with. */ export async function notifyBookingBarberChanged(prisma: PrismaClient, appointment: Appointment): Promise { await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.BOOKING_BARBER_CHANGED, recipientUserId: appointment.customer.userId ?? undefined, recipientCustomerId: appointment.customer.id, appointmentId: appointment.id, subject: "Your barber has changed", bodyText: `Your appointment for ${serviceNames(appointment)} is now with ${appointment.staff.user.name}.`, deepLinkPath: `/appointments/${appointment.id}`, idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`, }); } /** The appointment reaches COMPLETED — a distinct, non-loyalty "thanks for visiting" notice (see LoyaltyService for the separate stamp/reward notifications, which fire independently). */ export async function notifyBookingCompleted(prisma: PrismaClient, appointment: Appointment): Promise { await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.BOOKING_COMPLETED, recipientUserId: appointment.customer.userId ?? undefined, recipientCustomerId: appointment.customer.id, appointmentId: appointment.id, subject: "Thanks for visiting", bodyText: `Thanks for visiting ${appointment.staff.user.name ? `and seeing ${appointment.staff.user.name}` : ""} — hope you loved your ${serviceNames(appointment)}!`, deepLinkPath: `/appointments/${appointment.id}`, idempotencyKey: `notify:booking-completed:${appointment.id}`, }); } /** Customer marked NO_SHOW — owner-facing only (the barber already knows, they're the one who marked it). */ export async function notifyAppointmentNoShow(prisma: PrismaClient, appointment: Appointment): Promise { const timezone = await getBusinessTimezone(prisma, appointment.businessId); const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId); for (const ownerUserId of ownerUserIds) { await notifySafely(prisma, { businessId: appointment.businessId, type: NotificationType.OWNER_NO_SHOW, recipientUserId: ownerUserId, appointmentId: appointment.id, subject: "No-show recorded", bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} didn't show up for their appointment with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)}.`, deepLinkPath: `/owner/appointments/${appointment.id}`, idempotencyKey: `notify:owner-no-show:${appointment.id}:${ownerUserId}`, }); } }