src/services/notifications/types.ts — every STAFF_*/OWNER_* type is now preference-controlled (customer-facing types are untouched except the existing BOOKING_REMINDER, per your scope call). src/services/notifications/notification.service.ts — IN_APP now respects the same preference check as EMAIL/PUSH. For every type outside the controlled set (the vast majority) this changes nothing — isChannelEnabled still short-circuits to true. notification-preferences-form.tsx — generalized from one hardcoded toggle to a real per-type grid (groups prop), each row with Email/Push/In-app columns. Staff settings → Notifications (page.tsx) — now shows 6 staff-facing toggle groups always, plus 5 owner-facing ones only for whoever actually holds VIEW_REPORTING (the same permission that defines "owner" elsewhere in the app) — so a plain barber never sees owner-only switches that could never apply to them. Customer settings → Notifications — unchanged behavior, just updated to the new shared component's shape.
This commit is contained in:
9 files changed
+250
-78
No files matched your search
@@ -178,6 +178,64 @@ describe("NotificationService (integration)", () => {
|
||||
const preferences = await getNotificationPreferences(prisma, (await createTestCustomer(business)).userId);
|
||||
expect(preferences).toHaveLength(0); // no rows yet — resolveChannels() still includes EMAIL by default
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression coverage for the owner/staff preference expansion: the
|
||||
* owner of a business is commonly also its only barber (one
|
||||
* StaffProfile, one user account), so a single new booking used to
|
||||
* unconditionally notify that one person twice — STAFF_NEW_APPOINTMENT
|
||||
* and OWNER_NEW_BOOKING — on every channel including IN_APP, with no
|
||||
* way to quiet either copy. Both are now preference-controlled (see
|
||||
* notifications/types.ts), and IN_APP is preference-checked too (see
|
||||
* resolveChannels), not just EMAIL/PUSH.
|
||||
*/
|
||||
it("an owner can disable IN_APP for OWNER_NEW_BOOKING without affecting STAFF_NEW_APPOINTMENT for the same person", async () => {
|
||||
const { userId } = await createTestCustomer(business); // any real user id works — this is about preference plumbing, not booking semantics
|
||||
await setNotificationPreference(prisma, {
|
||||
userId,
|
||||
type: NotificationType.OWNER_NEW_BOOKING,
|
||||
channel: NotificationChannel.IN_APP,
|
||||
isEnabled: false,
|
||||
});
|
||||
|
||||
const { notification: ownerNotification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.OWNER_NEW_BOOKING,
|
||||
recipientUserId: userId,
|
||||
bodyText: "New booking.",
|
||||
idempotencyKey: `test:${userId}:owner-new-booking`,
|
||||
});
|
||||
expect(ownerNotification.deliveries.map((d) => d.channel)).not.toContain(NotificationChannel.IN_APP);
|
||||
|
||||
const { notification: staffNotification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
recipientUserId: userId,
|
||||
bodyText: "New appointment.",
|
||||
idempotencyKey: `test:${userId}:staff-new-appointment`,
|
||||
});
|
||||
expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
|
||||
});
|
||||
|
||||
it("setNotificationPreference now accepts every STAFF/OWNER-facing type (previously refused everything but the two reminders)", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
await expect(
|
||||
setNotificationPreference(prisma, {
|
||||
userId,
|
||||
type: NotificationType.OWNER_NEW_BOOKING,
|
||||
channel: NotificationChannel.EMAIL,
|
||||
isEnabled: false,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
setNotificationPreference(prisma, {
|
||||
userId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
|
||||
channel: NotificationChannel.PUSH,
|
||||
isEnabled: false,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-app notification centre", () => {
|
||||
|
||||
@@ -16,18 +16,29 @@ describe("recipientKindOf", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Policy: every STAFF/OWNER-facing type is preference-controlled (the
|
||||
* owner of a business is commonly also its only barber — a single
|
||||
* account that would otherwise always get told about the same event
|
||||
* twice, with no way to quiet either copy); every CUSTOMER-facing type
|
||||
* stays required except BOOKING_REMINDER, since a customer not finding
|
||||
* out their appointment was cancelled is a real risk, not just noise.
|
||||
* See types.ts's own header on PREFERENCE_CONTROLLED_TYPES.
|
||||
*/
|
||||
describe("isPreferenceControlled", () => {
|
||||
it("only the two reminder types are preference-controlled", () => {
|
||||
expect(isPreferenceControlled(NotificationType.BOOKING_REMINDER)).toBe(true);
|
||||
expect(isPreferenceControlled(NotificationType.STAFF_APPOINTMENT_REMINDER)).toBe(true);
|
||||
it("every STAFF- and OWNER-facing type is preference-controlled", () => {
|
||||
for (const type of Object.values(NotificationType)) {
|
||||
const kind = recipientKindOf(type);
|
||||
if (kind === "STAFF" || kind === "OWNER") {
|
||||
expect(isPreferenceControlled(type)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every required transactional type is not preference-controlled", () => {
|
||||
const required = Object.values(NotificationType).filter(
|
||||
(t) => t !== NotificationType.BOOKING_REMINDER && t !== NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
);
|
||||
for (const type of required) {
|
||||
expect(isPreferenceControlled(type)).toBe(false);
|
||||
it("BOOKING_REMINDER is the only preference-controlled CUSTOMER-facing type", () => {
|
||||
const customerTypes = Object.values(NotificationType).filter((t) => recipientKindOf(t) === "CUSTOMER");
|
||||
for (const type of customerTypes) {
|
||||
expect(isPreferenceControlled(type)).toBe(type === NotificationType.BOOKING_REMINDER);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -122,8 +122,17 @@ async function resolveChannels(
|
||||
): Promise<NotificationChannel[]> {
|
||||
const channels: NotificationChannel[] = [];
|
||||
|
||||
// IN_APP — only meaningful for a recipient with an account to sign into.
|
||||
if (params.recipientUserId) {
|
||||
// IN_APP — only meaningful for a recipient with an account to sign
|
||||
// into. Preference-gated too (not just EMAIL/PUSH below) as of the
|
||||
// owner/staff preference expansion — isChannelEnabled already no-ops
|
||||
// to true for every type outside PREFERENCE_CONTROLLED_TYPES, so this
|
||||
// changes nothing for the many required types; it's what lets someone
|
||||
// who's both the owner and the assigned barber turn off one of the two
|
||||
// in-app entries a single booking used to always create for them.
|
||||
if (
|
||||
params.recipientUserId &&
|
||||
(await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.IN_APP }))
|
||||
) {
|
||||
channels.push(NotificationChannel.IN_APP);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,14 +43,41 @@ export function recipientKindOf(type: NotificationType): NotificationRecipientKi
|
||||
|
||||
/**
|
||||
* Every other type is REQUIRED — always sent on every channel it has a
|
||||
* valid target for, with no user-facing toggle. Only these two are ever
|
||||
* looked up in NotificationPreference (see NotificationService.notify).
|
||||
* Deliberately a short, explicit list rather than a "category" column:
|
||||
* the set of optional types is a product decision, not a schema fact.
|
||||
* valid target for, with no user-facing toggle. Only the types listed
|
||||
* here are ever looked up in NotificationPreference (see
|
||||
* NotificationService.notify). Deliberately an explicit list rather than
|
||||
* a "category" column: the set of optional types is a product decision,
|
||||
* not a schema fact.
|
||||
*
|
||||
* Every CUSTOMER-facing type stays required except BOOKING_REMINDER — a
|
||||
* customer not finding out their appointment was cancelled is a real
|
||||
* risk, not just noise. Every STAFF/OWNER-facing type is controllable:
|
||||
* the owner of a business IS commonly also its only barber (a single
|
||||
* StaffProfile, one user account), which meant e.g. every new booking
|
||||
* unconditionally fired both STAFF_NEW_APPOINTMENT and OWNER_NEW_BOOKING
|
||||
* at that one person — on email, push, AND in-app (see resolveChannels
|
||||
* below, which now checks preference for IN_APP too, not just
|
||||
* EMAIL/PUSH) — with no way to quiet the duplicate. See
|
||||
* src/features/staff/components/notification-preferences-form.tsx for
|
||||
* where these are actually exposed.
|
||||
*/
|
||||
const PREFERENCE_CONTROLLED_TYPES = new Set<NotificationType>([
|
||||
// Customer-facing
|
||||
NotificationType.BOOKING_REMINDER,
|
||||
// Staff-facing
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.STAFF_APPOINTMENT_CANCELLED,
|
||||
NotificationType.STAFF_APPOINTMENT_CHECKED_IN,
|
||||
NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
NotificationType.STAFF_SCHEDULE_CHANGED,
|
||||
NotificationType.STAFF_INVITATION_RECEIVED,
|
||||
// Owner-facing
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_BOOKING_CANCELLED,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
|
||||
NotificationType.OWNER_REPEATED_DELIVERY_FAILURE,
|
||||
]);
|
||||
|
||||
export function isPreferenceControlled(type: NotificationType): boolean {
|
||||
|
||||
Reference in new issue
Block a user