Notification Settings
CI / ci (push) Canceled after 0s

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:
Stefan Lazic committed 2026-09-12 09:41:37 +01:00
1 parent bfa13c5f15
commit f24d22fdf1
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);
}
+31 -4
View File
@@ -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 {