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.
-