From f24d22fdf127b1b829ab33b861b3951677c7eae3 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sat, 12 Sep 2026 09:41:37 +0100 Subject: [PATCH] Notification Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../settings/notifications/page.tsx | 24 +++-- .../staff/settings/notifications/page.tsx | 58 +++++++++--- .../notifications/actions/preferences.ts | 12 +-- src/features/staff/actions/profile.ts | 10 ++- .../notification-preferences-form.tsx | 89 +++++++++++-------- .../notification.service.integration.test.ts | 58 ++++++++++++ .../notifications/__tests__/types.test.ts | 29 ++++-- .../notifications/notification.service.ts | 13 ++- src/services/notifications/types.ts | 35 +++++++- 9 files changed, 250 insertions(+), 78 deletions(-) diff --git a/src/app/(customer)/settings/notifications/page.tsx b/src/app/(customer)/settings/notifications/page.tsx index 7e3a9de..08a845e 100644 --- a/src/app/(customer)/settings/notifications/page.tsx +++ b/src/app/(customer)/settings/notifications/page.tsx @@ -6,31 +6,39 @@ import { NotificationChannel, NotificationType } from "@/generated/prisma/enums" import { prisma } from "@/lib/db/prisma"; import { getNotificationPreferences } from "@/services/notifications/notification.service"; import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; -import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; +import type { NotificationPreferenceGroup, PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle"; import { saveCustomerNotificationPreferencesAction } from "@/features/notifications/actions/preferences"; export const metadata: Metadata = { title: "Notifications" }; export const dynamic = "force-dynamic"; -const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH]; +const CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH, NotificationChannel.IN_APP]; +const GROUPS: NotificationPreferenceGroup[] = [{ type: NotificationType.BOOKING_REMINDER, label: "Appointment reminders" }]; export default async function CustomerNotificationSettingsPage() { const session = await auth(); if (!session?.user) redirect("/sign-in?callbackUrl=/settings/notifications"); const rows = await getNotificationPreferences(prisma, session.user.id); - const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({ - channel, - isEnabled: - rows.find((r) => r.type === NotificationType.BOOKING_REMINDER && r.channel === channel)?.isEnabled ?? true, - })); + const cells: PreferenceCellState[] = GROUPS.flatMap((group) => + CHANNELS.map((channel) => ({ + type: group.type, + channel, + isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? true, + })), + ); return (
- +

Push Notifications

diff --git a/src/app/(staff)/staff/settings/notifications/page.tsx b/src/app/(staff)/staff/settings/notifications/page.tsx index f9c7892..caaca66 100644 --- a/src/app/(staff)/staff/settings/notifications/page.tsx +++ b/src/app/(staff)/staff/settings/notifications/page.tsx @@ -2,36 +2,74 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { TopNav } from "@/components/navigation/top-nav"; -import { NotificationChannel, NotificationType } from "@/generated/prisma/enums"; +import { NotificationChannel, NotificationType, Permission } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; import { getNotificationPreferences } from "@/services/notifications/notification.service"; import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; -import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; +import type { NotificationPreferenceGroup, PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle"; import { saveNotificationPreferencesAction } from "@/features/staff/actions/profile"; export const metadata: Metadata = { title: "Notifications" }; export const dynamic = "force-dynamic"; -const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH]; +const CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH, NotificationChannel.IN_APP]; + +const STAFF_GROUPS: NotificationPreferenceGroup[] = [ + { type: NotificationType.STAFF_NEW_APPOINTMENT, label: "New appointments" }, + { type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, label: "Appointment rescheduled" }, + { type: NotificationType.STAFF_APPOINTMENT_CANCELLED, label: "Appointment cancelled" }, + { type: NotificationType.STAFF_APPOINTMENT_REMINDER, label: "Upcoming appointment reminders" }, + { type: NotificationType.STAFF_SCHEDULE_CHANGED, label: "Your schedule changed" }, + { type: NotificationType.STAFF_INVITATION_RECEIVED, label: "Team invitations" }, +]; + +// Shown only to whoever actually holds VIEW_REPORTING (i.e. is an owner — +// see services/notifications/recipients.ts's getOwnerUserIds, the same +// permission it checks). Someone who is BOTH staff and owner (a solo +// barbershop's owner, one StaffProfile, one account) is exactly who this +// page exists to help — a new booking used to unconditionally notify +// them twice (STAFF_NEW_APPOINTMENT + OWNER_NEW_BOOKING) on every +// channel, including in-app, with no way to quiet either one. +const OWNER_GROUPS: NotificationPreferenceGroup[] = [ + { type: NotificationType.OWNER_NEW_BOOKING, label: "New bookings" }, + { type: NotificationType.OWNER_BOOKING_CANCELLED, label: "Bookings cancelled" }, + { type: NotificationType.OWNER_NO_SHOW, label: "No-shows" }, + { type: NotificationType.OWNER_STAFF_INVITATION_ACCEPTED, label: "Staff invitation accepted" }, + { type: NotificationType.OWNER_REPEATED_DELIVERY_FAILURE, label: "Delivery-failure alerts" }, +]; export default async function StaffNotificationsPage() { const session = await auth(); if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/notifications"); + const isOwner = await hasPermission(session.user.id, Permission.VIEW_REPORTING); + const groups = isOwner ? [...STAFF_GROUPS, ...OWNER_GROUPS] : STAFF_GROUPS; + const rows = await getNotificationPreferences(prisma, session.user.id); - const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({ - channel, - isEnabled: - rows.find((r) => r.type === NotificationType.STAFF_APPOINTMENT_REMINDER && r.channel === channel) - ?.isEnabled ?? true, - })); + const cells: PreferenceCellState[] = groups.flatMap((group) => + CHANNELS.map((channel) => ({ + type: group.type, + channel, + isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? true, + })), + ); return (
- +

Push Notifications

diff --git a/src/features/notifications/actions/preferences.ts b/src/features/notifications/actions/preferences.ts index e9f3469..47dad7c 100644 --- a/src/features/notifications/actions/preferences.ts +++ b/src/features/notifications/actions/preferences.ts @@ -1,7 +1,6 @@ "use server"; import { auth } from "@/auth"; -import { NotificationType } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db/prisma"; import { setNotificationPreference } from "@/services/notifications/notification.service"; import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; @@ -13,9 +12,12 @@ export interface PreferencesActionResult { /** * The customer-side counterpart to features/staff/actions/profile.ts's - * saveNotificationPreferencesAction — same shape, but scoped to the - * session's own customer account and the one preference-controlled - * customer-facing type (BOOKING_REMINDER; see notifications/types.ts). + * saveNotificationPreferencesAction — same shape, and now genuinely + * generic the same way (uses each cell's own `type` rather than a + * hardcoded one), though the customer settings page only ever sends + * BOOKING_REMINDER cells today — that's still the one preference + * -controlled customer-facing type (see notifications/types.ts); everything + * else customer-facing stays required. */ export async function saveCustomerNotificationPreferencesAction( cells: PreferenceCellState[], @@ -26,7 +28,7 @@ export async function saveCustomerNotificationPreferencesAction( for (const cell of cells) { await setNotificationPreference(prisma, { userId: session.user.id, - type: NotificationType.BOOKING_REMINDER, + type: cell.type, channel: cell.channel, isEnabled: cell.isEnabled, }); diff --git a/src/features/staff/actions/profile.ts b/src/features/staff/actions/profile.ts index 1f72199..2545951 100644 --- a/src/features/staff/actions/profile.ts +++ b/src/features/staff/actions/profile.ts @@ -1,7 +1,6 @@ "use server"; import { auth } from "@/auth"; -import { NotificationType } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db/prisma"; import { getActingStaff } from "@/lib/staff/get-acting-staff"; import { setNotificationPreference } from "@/services/notifications/notification.service"; @@ -42,6 +41,13 @@ export async function updateProfileAction(input: { * has no StaffProfile, see isSuperAdmin) still has their own notification * preferences, so this checks session auth directly rather than requiring * getActingStaff() the way the staff-specific actions above do. + * + * Handles both staff-facing and owner-facing types — the same signed-in + * user is often both (see notifications/types.ts's own header on why + * that's exactly the case this expansion exists for), and + * setNotificationPreference() itself refuses anything not in + * PREFERENCE_CONTROLLED_TYPES, so this can't be used to silently disable + * a required type no matter what a caller sends. */ export async function saveNotificationPreferencesAction( cells: PreferenceCellState[], @@ -52,7 +58,7 @@ export async function saveNotificationPreferencesAction( for (const cell of cells) { await setNotificationPreference(prisma, { userId: session.user.id, - type: NotificationType.STAFF_APPOINTMENT_REMINDER, + type: cell.type, channel: cell.channel, isEnabled: cell.isEnabled, }); diff --git a/src/features/staff/components/notification-preferences-form.tsx b/src/features/staff/components/notification-preferences-form.tsx index b3c845a..9e74124 100644 --- a/src/features/staff/components/notification-preferences-form.tsx +++ b/src/features/staff/components/notification-preferences-form.tsx @@ -3,43 +3,54 @@ import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; -import { NotificationChannel } from "@/generated/prisma/enums"; +import { NotificationChannel, NotificationType } from "@/generated/prisma/enums"; -const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH]; +const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH, NotificationChannel.IN_APP]; const CHANNEL_LABEL: Record = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" }; export interface PreferenceCellState { + type: NotificationType; channel: NotificationChannel; isEnabled: boolean; } -/** - * Every other notification a barber gets (new appointment, reschedule, - * cancellation, schedule change) is required — always sent, no toggle — - * so this form only ever shows the one type that's genuinely optional: - * appointment reminders. See NotificationService/types.ts, - * PREFERENCE_CONTROLLED_TYPES, and docs/phase-7-summary.md — showing a - * toggle for a required type would just be a no-op switch that does - * nothing when flipped, which the Phase 7 brief explicitly calls out to - * avoid. - */ -export interface NotificationPreferencesFormProps { - initial: PreferenceCellState[]; - /** Recipient-specific — see features/staff/actions/profile.ts (staff) and features/notifications/actions/preferences.ts (customer). */ - action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>; +/** One togglable row — a type the caller has already confirmed is in PREFERENCE_CONTROLLED_TYPES (see notifications/types.ts); this component doesn't re-check that itself. */ +export interface NotificationPreferenceGroup { + type: NotificationType; + label: string; } -export function NotificationPreferencesForm({ initial, action }: NotificationPreferencesFormProps) { +/** + * Generic across every caller (customer/staff/owner) — which types are + * shown, and what they're labelled, is entirely up to `groups`. The + * customer settings page passes exactly one group (appointment + * reminders, its only optional type); the staff settings page passes + * several, plus a conditional set of OWNER_* groups when the signed-in + * user actually holds VIEW_REPORTING (see that page for why — showing an + * owner-only toggle to someone who isn't one would just be a switch that + * can never matter to them). + */ +export interface NotificationPreferencesFormProps { + groups: NotificationPreferenceGroup[]; + initial: PreferenceCellState[]; + /** Recipient-specific — see features/staff/actions/profile.ts (staff/owner) and features/notifications/actions/preferences.ts (customer). */ + action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>; + /** Copy shown above the toggle groups — differs by audience (e.g. "Booking confirmations always notify you..." for customers vs staff/owner's own framing). */ + description: string; +} + +export function NotificationPreferencesForm({ groups, initial, action, description }: NotificationPreferencesFormProps) { const router = useRouter(); const [cells, setCells] = useState(initial); const [saved, setSaved] = useState(false); const [isPending, startTransition] = useTransition(); - const isEnabled = (channel: NotificationChannel) => cells.find((c) => c.channel === channel)?.isEnabled ?? true; + const isEnabled = (type: NotificationType, channel: NotificationChannel) => + cells.find((c) => c.type === type && c.channel === channel)?.isEnabled ?? true; - const toggle = (channel: NotificationChannel) => { + const toggle = (type: NotificationType, channel: NotificationChannel) => { setSaved(false); - setCells((prev) => prev.map((c) => (c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c))); + setCells((prev) => prev.map((c) => (c.type === type && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c))); }; const handleSave = () => { @@ -52,25 +63,27 @@ export function NotificationPreferencesForm({ initial, action }: NotificationPre return (
-

- Booking confirmations, changes, and cancellations always notify you — those aren't - optional. Choose how you'd like to hear about upcoming appointment reminders. -

+

{description}

-
- {CHANNEL_ORDER.map((channel) => ( -
- Reminders via {CHANNEL_LABEL[channel]} -
+ ))}
))}
diff --git a/src/services/notifications/__tests__/notification.service.integration.test.ts b/src/services/notifications/__tests__/notification.service.integration.test.ts index 58b63d2..219ab55 100644 --- a/src/services/notifications/__tests__/notification.service.integration.test.ts +++ b/src/services/notifications/__tests__/notification.service.integration.test.ts @@ -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", () => { diff --git a/src/services/notifications/__tests__/types.test.ts b/src/services/notifications/__tests__/types.test.ts index d91fab3..7c19709 100644 --- a/src/services/notifications/__tests__/types.test.ts +++ b/src/services/notifications/__tests__/types.test.ts @@ -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); } }); }); diff --git a/src/services/notifications/notification.service.ts b/src/services/notifications/notification.service.ts index fbfeb78..af70746 100644 --- a/src/services/notifications/notification.service.ts +++ b/src/services/notifications/notification.service.ts @@ -122,8 +122,17 @@ async function resolveChannels( ): Promise { 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); } diff --git a/src/services/notifications/types.ts b/src/services/notifications/types.ts index 8bf6299..b610783 100644 --- a/src/services/notifications/types.ts +++ b/src/services/notifications/types.ts @@ -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([ + // 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 {