Your site's{" "}
diff --git a/src/features/pwa/__tests__/sw-badge.test.ts b/src/features/pwa/__tests__/sw-badge.test.ts
new file mode 100644
index 0000000..d4469ed
--- /dev/null
+++ b/src/features/pwa/__tests__/sw-badge.test.ts
@@ -0,0 +1,90 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { runInNewContext } from "node:vm";
+import { describe, expect, it, vi } from "vitest";
+
+type Handler = (event: Record) => void;
+
+/** Loads the real public/sw.js into a fake service-worker global and returns its event handlers. */
+function loadServiceWorker(options: {
+ shownNotifications: number;
+ appVisible: boolean;
+ badging?: boolean;
+}) {
+ const handlers: Record = {};
+ const setAppBadge = vi.fn().mockResolvedValue(undefined);
+ const clearAppBadge = vi.fn().mockResolvedValue(undefined);
+ const showNotification = vi.fn().mockResolvedValue(undefined);
+ const shown = { count: options.shownNotifications };
+
+ const self: Record = {
+ addEventListener: (type: string, handler: Handler) => {
+ handlers[type] = handler;
+ },
+ navigator: options.badging === false ? {} : { setAppBadge, clearAppBadge },
+ registration: {
+ showNotification,
+ getNotifications: async () => Array.from({ length: shown.count }, () => ({})),
+ },
+ clients: {
+ matchAll: async () => [
+ { visibilityState: options.appVisible ? "visible" : "hidden", url: "/" },
+ ],
+ openWindow: vi.fn(),
+ },
+ location: { origin: "https://example.test" },
+ };
+ self.self = self;
+
+ const source = readFileSync(join(process.cwd(), "public/sw.js"), "utf8");
+ runInNewContext(source, { ...self, self, caches: {}, URL, Response, Promise });
+
+ const dispatch = async (type: string, event: Record) => {
+ const pending: Promise[] = [];
+ handlers[type]!({ ...event, waitUntil: (p: Promise) => pending.push(p) });
+ await Promise.all(pending);
+ };
+
+ return { dispatch, setAppBadge, clearAppBadge, showNotification, shown };
+}
+
+const pushEvent = {
+ data: {
+ json: () => ({ title: "Booking rescheduled", body: "Moved to 14:00", url: "/appointments" }),
+ },
+};
+
+describe("service worker app-icon badge", () => {
+ it("badges the icon with the number of notifications still showing after a push", async () => {
+ const sw = loadServiceWorker({ shownNotifications: 3, appVisible: false });
+ await sw.dispatch("push", pushEvent);
+
+ expect(sw.showNotification).toHaveBeenCalledOnce();
+ expect(sw.setAppBadge).toHaveBeenCalledWith(3);
+ });
+
+ it("does not badge the icon while the app is open in front of the person", async () => {
+ const sw = loadServiceWorker({ shownNotifications: 1, appVisible: true });
+ await sw.dispatch("push", pushEvent);
+
+ expect(sw.showNotification).toHaveBeenCalledOnce();
+ expect(sw.setAppBadge).not.toHaveBeenCalled();
+ });
+
+ it("updates the badge when a notification is tapped, clearing it once none are left", async () => {
+ const sw = loadServiceWorker({ shownNotifications: 0, appVisible: false });
+ await sw.dispatch("notificationclick", {
+ notification: { close: vi.fn(), data: { url: "/appointments" } },
+ });
+
+ expect(sw.clearAppBadge).toHaveBeenCalledOnce();
+ expect(sw.setAppBadge).not.toHaveBeenCalled();
+ });
+
+ it("still shows the notification where the Badging API is unavailable", async () => {
+ const sw = loadServiceWorker({ shownNotifications: 2, appVisible: false, badging: false });
+ await sw.dispatch("push", pushEvent);
+
+ expect(sw.showNotification).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/features/pwa/components/service-worker-manager.tsx b/src/features/pwa/components/service-worker-manager.tsx
index 1bfd603..f81110e 100644
--- a/src/features/pwa/components/service-worker-manager.tsx
+++ b/src/features/pwa/components/service-worker-manager.tsx
@@ -2,6 +2,21 @@
import { useEffect, useState } from "react";
+/**
+ * The app icon's badge number counts push notifications still sitting in
+ * the notification shelf (see updateAppBadge in public/sw.js). Opening the
+ * app is the "I've seen it" signal, so clear both together — otherwise the
+ * next push would count notifications the person already dealt with.
+ */
+function clearAppBadgeAndNotifications() {
+ const badging = navigator as Navigator & { clearAppBadge?: () => Promise };
+ badging.clearAppBadge?.().catch(() => {});
+ navigator.serviceWorker?.ready
+ .then((registration) => registration.getNotifications())
+ .then((notifications) => notifications.forEach((notification) => notification.close()))
+ .catch(() => {});
+}
+
/**
* Registers the service worker once, app-wide, and surfaces the
* update-available handshake public/sw.js sets up: a new SW version
@@ -17,6 +32,12 @@ export function ServiceWorkerManager() {
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
+ const onVisibilityChange = () => {
+ if (document.visibilityState === "visible") clearAppBadgeAndNotifications();
+ };
+ onVisibilityChange();
+ document.addEventListener("visibilitychange", onVisibilityChange);
+
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
@@ -48,7 +69,10 @@ export function ServiceWorkerManager() {
window.location.reload();
};
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
- return () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
+ return () => {
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
+ };
}, []);
if (!waitingWorker || dismissed) return null;
diff --git a/src/features/staff/components/notification-preferences-form.tsx b/src/features/staff/components/notification-preferences-form.tsx
index 9e74124..fb5110c 100644
--- a/src/features/staff/components/notification-preferences-form.tsx
+++ b/src/features/staff/components/notification-preferences-form.tsx
@@ -4,8 +4,13 @@ import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
+import { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app";
-const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH, NotificationChannel.IN_APP];
+const CHANNEL_ORDER: NotificationChannel[] = [
+ NotificationChannel.EMAIL,
+ NotificationChannel.PUSH,
+ ...(IN_APP_NOTIFICATIONS_VISIBLE ? [NotificationChannel.IN_APP] : []),
+];
const CHANNEL_LABEL: Record = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
export interface PreferenceCellState {
diff --git a/src/services/loyalty/__tests__/loyalty-notifications.integration.test.ts b/src/services/loyalty/__tests__/loyalty-notifications.integration.test.ts
index a61e6a4..520d0f3 100644
--- a/src/services/loyalty/__tests__/loyalty-notifications.integration.test.ts
+++ b/src/services/loyalty/__tests__/loyalty-notifications.integration.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RewardType } from "@/generated/prisma/enums";
import { addStamp, awardStampForCompletedAppointment, redeemReward } from "../loyalty.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures";
@@ -10,6 +10,10 @@ import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
* channel/preference logic is covered elsewhere; this file is "does the
* loyalty workflow call notify() correctly, exactly once per real event".
*/
+// These tests exercise the in-app notification centre, which production keeps
+// switched off (see ../in-app.ts) — turn it on here so that machinery stays covered.
+vi.mock("@/services/notifications/in-app", () => ({ IN_APP_NOTIFICATIONS_VISIBLE: true }));
+
describe("loyalty notifications (integration)", () => {
let business: TestBusiness;
diff --git a/src/services/notifications/__tests__/notification.in-app-hidden.integration.test.ts b/src/services/notifications/__tests__/notification.in-app-hidden.integration.test.ts
new file mode 100644
index 0000000..f79783a
--- /dev/null
+++ b/src/services/notifications/__tests__/notification.in-app-hidden.integration.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
+import {
+ cleanupTestBusiness,
+ createTestBusiness,
+ createTestCustomer,
+ prisma,
+} from "@/services/scheduling/__tests__/fixtures";
+import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
+import { IN_APP_NOTIFICATIONS_VISIBLE } from "../in-app";
+import { notify } from "../notification.service";
+
+// Deliberately does not mock ../in-app: this file covers the shipped default.
+describe("NotificationService with in-app notifications switched off (integration)", () => {
+ let business: TestBusiness;
+
+ beforeEach(async () => {
+ business = await createTestBusiness();
+ });
+
+ afterEach(async () => {
+ await cleanupTestBusiness(business);
+ });
+
+ it("ships with in-app notifications off", () => {
+ expect(IN_APP_NOTIFICATIONS_VISIBLE).toBe(false);
+ });
+
+ it("creates no IN_APP delivery, even for a registered customer", async () => {
+ const { customerId, userId } = await createTestCustomer(business);
+
+ const { notification } = await notify(prisma, {
+ businessId: business.businessId,
+ type: NotificationType.BOOKING_CONFIRMED,
+ recipientUserId: userId,
+ recipientCustomerId: customerId,
+ bodyText: "Your booking is confirmed.",
+ idempotencyKey: `test:${userId}:in-app-hidden`,
+ });
+
+ const channels = notification.deliveries.map((d) => d.channel);
+ expect(channels).toEqual([NotificationChannel.EMAIL]);
+ });
+});
diff --git a/src/services/notifications/__tests__/notification.service.integration.test.ts b/src/services/notifications/__tests__/notification.service.integration.test.ts
index 0492847..3cee1b0 100644
--- a/src/services/notifications/__tests__/notification.service.integration.test.ts
+++ b/src/services/notifications/__tests__/notification.service.integration.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
@@ -20,6 +20,10 @@ import {
setNotificationPreference,
} from "../notification.service";
+// These tests exercise the in-app notification centre, which production keeps
+// switched off (see ../in-app.ts) — turn it on here so that machinery stays covered.
+vi.mock("@/services/notifications/in-app", () => ({ IN_APP_NOTIFICATIONS_VISIBLE: true }));
+
describe("NotificationService (integration)", () => {
let business: TestBusiness;
let guestProfileIds: string[];
diff --git a/src/services/notifications/in-app.ts b/src/services/notifications/in-app.ts
new file mode 100644
index 0000000..2b2a463
--- /dev/null
+++ b/src/services/notifications/in-app.ts
@@ -0,0 +1,9 @@
+/**
+ * Whether in-app notifications exist at all. Off — nothing creates an
+ * IN_APP delivery (notification.service.ts's resolveChannels), and the UI
+ * hides the bell with its unread badge, the /notifications pages and the
+ * "In-app" toggle in notification settings, so notifications reach people
+ * by email and push only. Turning it back on restores all of it; rows
+ * created while it was on are kept.
+ */
+export const IN_APP_NOTIFICATIONS_VISIBLE = false;
diff --git a/src/services/notifications/notification.service.ts b/src/services/notifications/notification.service.ts
index e652239..d8084b7 100644
--- a/src/services/notifications/notification.service.ts
+++ b/src/services/notifications/notification.service.ts
@@ -1,6 +1,7 @@
import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
+import { IN_APP_NOTIFICATIONS_VISIBLE } from "./in-app";
import { defaultPreferenceIsEnabled, isPreferenceControlled } from "./types";
/**
@@ -144,6 +145,7 @@ async function resolveChannels(
// 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 (
+ IN_APP_NOTIFICATIONS_VISIBLE &&
params.recipientUserId &&
(await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.IN_APP }))
) {
diff --git a/src/services/scheduling/__tests__/booking-notifications.integration.test.ts b/src/services/scheduling/__tests__/booking-notifications.integration.test.ts
index 4099e16..4dd337a 100644
--- a/src/services/scheduling/__tests__/booking-notifications.integration.test.ts
+++ b/src/services/scheduling/__tests__/booking-notifications.integration.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
@@ -25,6 +25,10 @@ function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): Calen
* is covered by notification.service.integration.test.ts; this file is
* specifically "does the booking workflow call notify() correctly".
*/
+// These tests exercise the in-app notification centre, which production keeps
+// switched off (see ../in-app.ts) — turn it on here so that machinery stays covered.
+vi.mock("@/services/notifications/in-app", () => ({ IN_APP_NOTIFICATIONS_VISIBLE: true }));
+
describe("booking notifications (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
diff --git a/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts b/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts
index 6be5c90..2477315 100644
--- a/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts
+++ b/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures";
import { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums";
@@ -44,6 +44,10 @@ async function reminderDeliveries(appointmentId: string, channels: ("EMAIL" | "P
* Every scheduled IN_APP copy is created SCHEDULED, not SENT, so it stays
* out of the notification bell until the worker releases it.
*/
+// These tests exercise the in-app notification centre, which production keeps
+// switched off (see ../in-app.ts) — turn it on here so that machinery stays covered.
+vi.mock("@/services/notifications/in-app", () => ({ IN_APP_NOTIFICATIONS_VISIBLE: true }));
+
describe("reminder scheduling (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;