CI / ci (push) Canceled after 0s
Core logic: types.ts (defaults), notification.service.ts (skipEmail + dedup), booking-notifications.ts, loyalty-notifications.ts, reminder-scheduling.ts, booking.service.ts + scheduling.service.ts (staff-selection capture), features/owner/actions/appointments.ts. Schema: prisma/schema.prisma + new migration 20260912165903_add_staff_selected_by_customer. UI: both notification-preferences pages (customer, staff) now use the same default logic as the actual send decision. Tests: notification.service.integration.test.ts, booking-notifications.integration.test.ts, reminder-scheduling.integration.test.ts, loyalty-notifications.integration.test.ts, plus a new createTestAppointment fixture. Docs: docs/notifications.md rewritten to match. Default changes Exactly as specified — full table is in docs/notifications.md. In short: STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, OWNER_NO_SHOW now default off; everything else that was already toggleable is untouched. No longer email (fixed, not a toggle) BOOKING_COMPLETED, LOYALTY_STAMP_ADDED, LOYALTY_REWARD_REDEEMED — always. BOOKING_BARBER_CHANGED — only when the customer originally booked "any available barber." The customer's 2-hour reminder is removed entirely (24h stays); staff keeps both, just off by default. All via a new skipEmail flag on notify(), checked in one place — no new preference rows for these. Deduplication One new EQUIVALENCE_GROUPS map in notification.service.ts (STAFF_NEW_APPOINTMENT>OWNER_NEW_BOOKING, STAFF_APPOINTMENT_CANCELLED>OWNER_BOOKING_CANCELLED). Inside notify(), before creating deliveries, it checks for an already-created equivalent notification for the same (businessId, appointmentId, recipientUserId); if found and at-least-as-relevant, the new one gets zero deliveries on every channel (row still created for audit trail). Only listed types participate, so OWNER_NO_SHOW and any future actionable owner type are never touched.
258 lines
13 KiB
TypeScript
258 lines
13 KiB
TypeScript
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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.
|
|
*
|
|
* `customerSelectedStaff` should reflect the ORIGINAL booking (see
|
|
* Appointment.staffSelectedByCustomer), not the current staffId — a
|
|
* customer who booked "any available barber" and never expressed a
|
|
* preference doesn't need an email every time the business reassigns
|
|
* who's cutting their hair, though push/in-app still fire either way
|
|
* (the deep link is still useful, this is specifically about inbox
|
|
* noise). Defaults to true (email) so every existing call site keeps
|
|
* behaving exactly as before unless it explicitly opts out.
|
|
*/
|
|
export async function notifyBookingBarberChanged(
|
|
prisma: PrismaClient,
|
|
appointment: Appointment,
|
|
options: { customerSelectedStaff?: boolean } = {},
|
|
): Promise<void> {
|
|
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}`,
|
|
skipEmail: options.customerSelectedStaff === false,
|
|
});
|
|
}
|
|
|
|
/** The appointment reaches COMPLETED — a distinct, non-loyalty "thanks for visiting" notice (see LoyaltyService for the separate stamp/reward notifications, which fire independently). No longer emailed by default (still push/in-app) — a receipt-style email after every single visit was more noise than value. */
|
|
export async function notifyBookingCompleted(prisma: PrismaClient, appointment: Appointment): Promise<void> {
|
|
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}`,
|
|
skipEmail: true,
|
|
});
|
|
}
|
|
|
|
/** 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<void> {
|
|
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}`,
|
|
});
|
|
}
|
|
}
|