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
@@ -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) =>
|
||||||
channel,
|
CHANNELS.map((channel) => ({
|
||||||
isEnabled:
|
type: group.type,
|
||||||
rows.find((r) => r.type === NotificationType.BOOKING_REMINDER && r.channel === channel)?.isEnabled ?? true,
|
channel,
|
||||||
}));
|
isEnabled: rows.find((r) => r.type === group.type && 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) =>
|
||||||
channel,
|
CHANNELS.map((channel) => ({
|
||||||
isEnabled:
|
type: group.type,
|
||||||
rows.find((r) => r.type === NotificationType.STAFF_APPOINTMENT_REMINDER && r.channel === channel)
|
channel,
|
||||||
?.isEnabled ?? true,
|
isEnabled: rows.find((r) => r.type === group.type && 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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,25 +63,27 @@ 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't
|
|
||||||
optional. Choose how you'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">
|
||||||
{CHANNEL_ORDER.map((channel) => (
|
{groups.map((group) => (
|
||||||
<div key={channel} className="flex items-center justify-between px-4 py-3">
|
<div key={group.type} className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
|
||||||
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
|
<div className="bg-stone px-4 py-2 text-xs font-bold uppercase tracking-wide text-muted">{group.label}</div>
|
||||||
<button
|
{CHANNEL_ORDER.map((channel) => (
|
||||||
type="button"
|
<div key={channel} className="flex items-center justify-between px-4 py-3">
|
||||||
role="switch"
|
<span className="text-sm font-medium">{CHANNEL_LABEL[channel]}</span>
|
||||||
aria-checked={isEnabled(channel)}
|
<button
|
||||||
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
|
type="button"
|
||||||
onClick={() => toggle(channel)}
|
role="switch"
|
||||||
className={`h-5 w-5 rounded-md border transition-colors ${
|
aria-checked={isEnabled(group.type, channel)}
|
||||||
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white"
|
aria-label={`${group.label} via ${CHANNEL_LABEL[channel]}`}
|
||||||
}`}
|
onClick={() => toggle(group.type, channel)}
|
||||||
/>
|
className={`h-5 w-5 rounded-md border transition-colors ${
|
||||||
|
isEnabled(group.type, channel) ? "border-accent bg-accent" : "border-line bg-white"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user