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.
This commit is contained in:
18 files changed
+999
-172
No files matched your search
@@ -186,8 +186,28 @@ export async function notifyBookingApproved(prisma: PrismaClient, appointment: A
|
||||
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<void> {
|
||||
/**
|
||||
* 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,
|
||||
@@ -198,10 +218,11 @@ export async function notifyBookingBarberChanged(prisma: PrismaClient, appointme
|
||||
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). */
|
||||
/** 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,
|
||||
@@ -213,6 +234,7 @@ export async function notifyBookingCompleted(prisma: PrismaClient, appointment:
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user