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

@@ -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,
});
+8 -2
View File
@@ -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,
});
@@ -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<NotificationChannel, string> = { 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 (
<div className="space-y-4">
<p className="text-muted text-sm">
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>
<p className="text-muted text-sm">{description}</p>
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
{CHANNEL_ORDER.map((channel) => (
<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>
<button
type="button"
role="switch"
aria-checked={isEnabled(channel)}
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(channel) ? "border-accent bg-accent" : "border-line 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) => (
<div key={channel} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">{CHANNEL_LABEL[channel]}</span>
<button
type="button"
role="switch"
aria-checked={isEnabled(group.type, channel)}
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>