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
+240 -68

No files matched your search

@@ -6,31 +6,39 @@ import { NotificationChannel, NotificationType } from "@/generated/prisma/enums"
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getNotificationPreferences } from "@/services/notifications/notification.service"; import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; 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 { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
import { saveCustomerNotificationPreferencesAction } from "@/features/notifications/actions/preferences"; import { saveCustomerNotificationPreferencesAction } from "@/features/notifications/actions/preferences";
export const metadata: Metadata = { title: "Notifications" }; export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic"; 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() { export default async function CustomerNotificationSettingsPage() {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/notifications"); if (!session?.user) redirect("/sign-in?callbackUrl=/settings/notifications");
const rows = await getNotificationPreferences(prisma, session.user.id); const rows = await getNotificationPreferences(prisma, session.user.id);
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({ const cells: PreferenceCellState[] = GROUPS.flatMap((group) =>
CHANNELS.map((channel) => ({
type: group.type,
channel, channel,
isEnabled: isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? true,
rows.find((r) => r.type === NotificationType.BOOKING_REMINDER && r.channel === channel)?.isEnabled ?? true, })),
})); );
return ( return (
<div className="pb-12"> <div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" /> <TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6"> <main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<NotificationPreferencesForm initial={cells} action={saveCustomerNotificationPreferencesAction} /> <NotificationPreferencesForm
groups={GROUPS}
initial={cells}
action={saveCustomerNotificationPreferencesAction}
description="Booking confirmations, changes, and cancellations always notify you — those aren't optional. Choose how you'd like to hear about upcoming appointment reminders."
/>
<div className="rounded-md border-line border bg-white p-5"> <div className="rounded-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2> <h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle /> <PushSubscribeToggle />
@@ -2,36 +2,74 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav"; 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 { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { getNotificationPreferences } from "@/services/notifications/notification.service"; import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; 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 { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
import { saveNotificationPreferencesAction } from "@/features/staff/actions/profile"; import { saveNotificationPreferencesAction } from "@/features/staff/actions/profile";
export const metadata: Metadata = { title: "Notifications" }; export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic"; 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() { export default async function StaffNotificationsPage() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/notifications"); 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 rows = await getNotificationPreferences(prisma, session.user.id);
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({ const cells: PreferenceCellState[] = groups.flatMap((group) =>
CHANNELS.map((channel) => ({
type: group.type,
channel, channel,
isEnabled: isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? true,
rows.find((r) => r.type === NotificationType.STAFF_APPOINTMENT_REMINDER && r.channel === channel) })),
?.isEnabled ?? true, );
}));
return ( return (
<div className="pb-12"> <div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" /> <TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6"> <main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} /> <NotificationPreferencesForm
groups={groups}
initial={cells}
action={saveNotificationPreferencesAction}
description={
isOwner
? "Choose how you'd like to hear about each of these — as both a team member and the business owner, some events (like a new booking) would otherwise notify you twice."
: "Choose how you'd like to hear about each of these."
}
/>
<div className="rounded-md border-line border bg-white p-5"> <div className="rounded-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2> <h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle /> <PushSubscribeToggle />
@@ -1,7 +1,6 @@
"use server"; "use server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { setNotificationPreference } from "@/services/notifications/notification.service"; import { setNotificationPreference } from "@/services/notifications/notification.service";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; 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 * The customer-side counterpart to features/staff/actions/profile.ts's
* saveNotificationPreferencesAction — same shape, but scoped to the * saveNotificationPreferencesAction — same shape, and now genuinely
* session's own customer account and the one preference-controlled * generic the same way (uses each cell's own `type` rather than a
* customer-facing type (BOOKING_REMINDER; see notifications/types.ts). * 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( export async function saveCustomerNotificationPreferencesAction(
cells: PreferenceCellState[], cells: PreferenceCellState[],
@@ -26,7 +28,7 @@ export async function saveCustomerNotificationPreferencesAction(
for (const cell of cells) { for (const cell of cells) {
await setNotificationPreference(prisma, { await setNotificationPreference(prisma, {
userId: session.user.id, userId: session.user.id,
type: NotificationType.BOOKING_REMINDER, type: cell.type,
channel: cell.channel, channel: cell.channel,
isEnabled: cell.isEnabled, isEnabled: cell.isEnabled,
}); });
+8 -2
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff"; import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { setNotificationPreference } from "@/services/notifications/notification.service"; 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 * has no StaffProfile, see isSuperAdmin) still has their own notification
* preferences, so this checks session auth directly rather than requiring * preferences, so this checks session auth directly rather than requiring
* getActingStaff() the way the staff-specific actions above do. * 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( export async function saveNotificationPreferencesAction(
cells: PreferenceCellState[], cells: PreferenceCellState[],
@@ -52,7 +58,7 @@ export async function saveNotificationPreferencesAction(
for (const cell of cells) { for (const cell of cells) {
await setNotificationPreference(prisma, { await setNotificationPreference(prisma, {
userId: session.user.id, userId: session.user.id,
type: NotificationType.STAFF_APPOINTMENT_REMINDER, type: cell.type,
channel: cell.channel, channel: cell.channel,
isEnabled: cell.isEnabled, isEnabled: cell.isEnabled,
}); });
@@ -3,43 +3,54 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button"; 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<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" }; const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
export interface PreferenceCellState { export interface PreferenceCellState {
type: NotificationType;
channel: NotificationChannel; channel: NotificationChannel;
isEnabled: boolean; isEnabled: boolean;
} }
/** /** 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. */
* Every other notification a barber gets (new appointment, reschedule, export interface NotificationPreferenceGroup {
* cancellation, schedule change) is required — always sent, no toggle — type: NotificationType;
* so this form only ever shows the one type that's genuinely optional: label: string;
* 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 }>;
} }
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 router = useRouter();
const [cells, setCells] = useState(initial); const [cells, setCells] = useState(initial);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition(); 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); 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 = () => { const handleSave = () => {
@@ -52,28 +63,30 @@ export function NotificationPreferencesForm({ initial, action }: NotificationPre
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-muted text-sm"> <p className="text-muted text-sm">{description}</p>
Booking confirmations, changes, and cancellations always notify you — those aren&apos;t
optional. Choose how you&apos;d like to hear about upcoming appointment reminders.
</p>
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white"> <div className="space-y-3">
{groups.map((group) => (
<div key={group.type} className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
<div className="bg-stone px-4 py-2 text-xs font-bold uppercase tracking-wide text-muted">{group.label}</div>
{CHANNEL_ORDER.map((channel) => ( {CHANNEL_ORDER.map((channel) => (
<div key={channel} className="flex items-center justify-between px-4 py-3"> <div key={channel} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span> <span className="text-sm font-medium">{CHANNEL_LABEL[channel]}</span>
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={isEnabled(channel)} aria-checked={isEnabled(group.type, channel)}
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`} aria-label={`${group.label} via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(channel)} onClick={() => toggle(group.type, channel)}
className={`h-5 w-5 rounded-md border transition-colors ${ className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white" isEnabled(group.type, channel) ? "border-accent bg-accent" : "border-line bg-white"
}`} }`}
/> />
</div> </div>
))} ))}
</div> </div>
))}
</div>
{saved && ( {saved && (
<p role="status" className="text-accent-foreground text-sm font-medium"> <p role="status" className="text-accent-foreground text-sm font-medium">
@@ -178,6 +178,64 @@ describe("NotificationService (integration)", () => {
const preferences = await getNotificationPreferences(prisma, (await createTestCustomer(business)).userId); const preferences = await getNotificationPreferences(prisma, (await createTestCustomer(business)).userId);
expect(preferences).toHaveLength(0); // no rows yet — resolveChannels() still includes EMAIL by default 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", () => { 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", () => { describe("isPreferenceControlled", () => {
it("only the two reminder types are preference-controlled", () => { it("every STAFF- and OWNER-facing type is preference-controlled", () => {
expect(isPreferenceControlled(NotificationType.BOOKING_REMINDER)).toBe(true); for (const type of Object.values(NotificationType)) {
expect(isPreferenceControlled(NotificationType.STAFF_APPOINTMENT_REMINDER)).toBe(true); const kind = recipientKindOf(type);
if (kind === "STAFF" || kind === "OWNER") {
expect(isPreferenceControlled(type)).toBe(true);
}
}
}); });
it("every required transactional type is not preference-controlled", () => { it("BOOKING_REMINDER is the only preference-controlled CUSTOMER-facing type", () => {
const required = Object.values(NotificationType).filter( const customerTypes = Object.values(NotificationType).filter((t) => recipientKindOf(t) === "CUSTOMER");
(t) => t !== NotificationType.BOOKING_REMINDER && t !== NotificationType.STAFF_APPOINTMENT_REMINDER, for (const type of customerTypes) {
); expect(isPreferenceControlled(type)).toBe(type === NotificationType.BOOKING_REMINDER);
for (const type of required) {
expect(isPreferenceControlled(type)).toBe(false);
} }
}); });
}); });
@@ -122,8 +122,17 @@ async function resolveChannels(
): Promise<NotificationChannel[]> { ): Promise<NotificationChannel[]> {
const channels: NotificationChannel[] = []; const channels: NotificationChannel[] = [];
// IN_APP — only meaningful for a recipient with an account to sign into. // IN_APP — only meaningful for a recipient with an account to sign
if (params.recipientUserId) { // 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); 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 * 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 * valid target for, with no user-facing toggle. Only the types listed
* looked up in NotificationPreference (see NotificationService.notify). * here are ever looked up in NotificationPreference (see
* Deliberately a short, explicit list rather than a "category" column: * NotificationService.notify). Deliberately an explicit list rather than
* the set of optional types is a product decision, not a schema fact. * 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>([ const PREFERENCE_CONTROLLED_TYPES = new Set<NotificationType>([
// Customer-facing
NotificationType.BOOKING_REMINDER, 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_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 { export function isPreferenceControlled(type: NotificationType): boolean {