In App Notifications
CI / ci (push) Canceled after 0s

Policies section (business-details-form.tsx)

Late Arrival Policy, Booking Terms and Privacy Policy URL are gone. The section now has Cancellation Policy plus the Terms & Privacy name and email dropdowns.
Their database columns stay, so nothing is lost. Saving no longer touches them.

Email and push only

One switch, IN_APP_NOTIFICATIONS_VISIBLE in in-app.ts, is set to false and controls everything below.

Bell icon: gone for customers, staff and owners, including the unread badge.
Notification pages: /notifications, /staff/notifications and /owner/notifications now return 404.

Settings: Settings → Notifications shows only Email and Push toggles.
Testing: I checked all of this in a local test app as the owner.
This commit is contained in:
Stefan Lazic committed 2026-10-17 11:34:30 +01:00
1 parent 766a0808ab
commit 31c48296d9
19 files changed
+263 -40

No files matched your search

+3
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers"; import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers";
/** /**
@@ -166,6 +167,8 @@ test.describe("customer booking journeys", () => {
}); });
test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => { test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => {
test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
await signIn(page, SEEDED.registeredCustomer.email); await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/book"); await page.goto("/book");
+3
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { createPool } from "mariadb"; import { createPool } from "mariadb";
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers"; import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
/** /**
@@ -240,6 +241,8 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("10. barber receives a new-booking notification", async ({ page }) => { test("10. barber receives a new-booking notification", async ({ page }) => {
test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
// Deliberately books its own fresh appointment rather than relying on journey 3's — every // Deliberately books its own fresh appointment rather than relying on journey 3's — every
// other independent journey in this file follows that same "book a fresh appointment first" // other independent journey in this file follows that same "book a fresh appointment first"
// rule (see this file's header comment), and this one was the exception, which made it fail // rule (see this file's header comment), and this one was the exception, which made it fail
+46 -9
View File
@@ -5,8 +5,8 @@
// balances) — caching that would risk serving stale or, worse, another // balances) — caching that would risk serving stale or, worse, another
// user's data. So this file does NOT try to make the booking flow work // user's data. So this file does NOT try to make the booking flow work
// offline. What it does do: // offline. What it does do:
// - Push notifications (`push`/`notificationclick` — unchanged from the // - Push notifications (`push`/`notificationclick`), plus an app-icon
// original Phase 7 push-notifications work). // badge number counting the ones still unread (see updateAppBadge).
// - Precache + serve a small static app shell (offline fallback page, // - Precache + serve a small static app shell (offline fallback page,
// manifest, icons) so a lost connection shows a real "you're offline" // manifest, icons) so a lost connection shows a real "you're offline"
// screen instead of the browser's own dinosaur/error page. // screen instead of the browser's own dinosaur/error page.
@@ -92,6 +92,34 @@ self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
}); });
/**
* App-icon badge = how many of this app's push notifications are still
* sitting in the notification shelf. Counting what the OS is actually
* showing (instead of a stored counter) means nothing needs persisting —
* a service worker can be stopped at any moment, losing in-memory state.
* The page clears the badge and the shelf when the app is opened (see
* service-worker-manager.tsx).
*
* Needs the Badging API: installed PWAs on Android/desktop Chrome and
* Home Screen web apps on iOS 16.4+. Everywhere else this is a no-op.
*/
async function updateAppBadge() {
if (!("setAppBadge" in self.navigator)) return;
try {
const count = (await self.registration.getNotifications()).length;
if (count > 0) await self.navigator.setAppBadge(count);
else await self.navigator.clearAppBadge();
} catch {
// Badging is cosmetic — never let it break showing the notification.
}
}
/** True when the app is open and in front of the person — a badge on the icon is pointless (and stale the moment they leave) then. */
async function appIsInForeground() {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
return windows.some((client) => client.visibilityState === "visible");
}
self.addEventListener("push", (event) => { self.addEventListener("push", (event) => {
if (!event.data) return; if (!event.data) return;
@@ -114,7 +142,13 @@ self.addEventListener("push", (event) => {
badge: "/icons/icon-192.png", badge: "/icons/icon-192.png",
}; };
event.waitUntil(self.registration.showNotification(title, options)); event.waitUntil(
self.registration
.showNotification(title, options)
.then(async () => {
if (!(await appIsInForeground())) await updateAppBadge();
}),
);
}); });
self.addEventListener("notificationclick", (event) => { self.addEventListener("notificationclick", (event) => {
@@ -122,11 +156,14 @@ self.addEventListener("notificationclick", (event) => {
const url = event.notification.data?.url || "/"; const url = event.notification.data?.url || "/";
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { Promise.all([
for (const client of clientList) { updateAppBadge(),
if (client.url.includes(url) && "focus" in client) return client.focus(); self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
} for (const client of clientList) {
if (self.clients.openWindow) return self.clients.openWindow(url); if (client.url.includes(url) && "focus" in client) return client.focus();
}), }
if (self.clients.openWindow) return self.clients.openWindow(url);
}),
]),
); );
}); });
@@ -1,16 +1,20 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TopNav } from "@/components/navigation/top-nav"; import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { listInAppNotifications } from "@/services/notifications/notification.service"; import { listInAppNotifications } from "@/services/notifications/notification.service";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app";
import { NotificationList } from "@/features/notifications/components/notification-list"; import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" }; export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function CustomerNotificationsPage() { export default async function CustomerNotificationsPage() {
if (!IN_APP_NOTIFICATIONS_VISIBLE) notFound();
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
+4 -1
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav"; import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listInAppNotifications } from "@/services/notifications/notification.service"; import { listInAppNotifications } from "@/services/notifications/notification.service";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app";
import { NotificationList } from "@/features/notifications/components/notification-list"; import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" }; export const metadata: Metadata = { title: "Notifications" };
@@ -11,6 +12,8 @@ export const dynamic = "force-dynamic";
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */ /** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
export default async function OwnerNotificationsPage() { export default async function OwnerNotificationsPage() {
if (!IN_APP_NOTIFICATIONS_VISIBLE) notFound();
const context = await getActingBusinessContext(); const context = await getActingBusinessContext();
if (!context) redirect("/home"); if (!context) redirect("/home");
@@ -53,9 +53,6 @@ export default async function OwnerBusinessDetailsPage() {
postalCode: location?.postalCode ?? "", postalCode: location?.postalCode ?? "",
country: location?.country ?? "GB", country: location?.country ?? "GB",
cancellationPolicy: business.settings?.cancellationPolicy ?? "", cancellationPolicy: business.settings?.cancellationPolicy ?? "",
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR", legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR",
legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR", legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR",
}} }}
+4 -1
View File
@@ -1,15 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav"; import { TopNav } from "@/components/navigation/top-nav";
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 { listInAppNotifications } from "@/services/notifications/notification.service"; import { listInAppNotifications } from "@/services/notifications/notification.service";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app";
import { NotificationList } from "@/features/notifications/components/notification-list"; import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" }; export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function StaffNotificationsPage() { export default async function StaffNotificationsPage() {
if (!IN_APP_NOTIFICATIONS_VISIBLE) notFound();
const staff = await getActingStaff(); const staff = await getActingStaff();
if (!staff) redirect("/home"); if (!staff) redirect("/home");
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app";
/** Pages that render their own inline bell (see InlineNotificationBell) as part of a proper header — the shell's own floating bell steps aside there instead of sitting on top of it. */ /** Pages that render their own inline bell (see InlineNotificationBell) as part of a proper header — the shell's own floating bell steps aside there instead of sitting on top of it. */
const PAGES_WITH_INLINE_BELL = [ const PAGES_WITH_INLINE_BELL = [
@@ -41,7 +42,7 @@ function badge(unreadCount: number) {
*/ */
export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) { export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
const pathname = usePathname(); const pathname = usePathname();
if (PAGES_WITH_INLINE_BELL.includes(pathname)) return null; if (!IN_APP_NOTIFICATIONS_VISIBLE || PAGES_WITH_INLINE_BELL.includes(pathname)) return null;
return ( return (
<Link <Link
@@ -57,6 +58,7 @@ export function NotificationBell({ href, unreadCount }: { href: string; unreadCo
/** Same look as the fixed bell above, but a normal in-flow element — for a page's own TopNav `leftAction`, not the shell's floating corner. */ /** Same look as the fixed bell above, but a normal in-flow element — for a page's own TopNav `leftAction`, not the shell's floating corner. */
export function InlineNotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) { export function InlineNotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
if (!IN_APP_NOTIFICATIONS_VISIBLE) return null;
return ( return (
<Link <Link
href={href} href={href}
@@ -37,9 +37,6 @@ export interface BusinessDetailsInitial {
postalCode: string; postalCode: string;
country: string; country: string;
cancellationPolicy: string; cancellationPolicy: string;
lateArrivalPolicy: string;
bookingTerms: string;
privacyPolicyUrl: string;
legalNameDisplay: LegalNameDisplay; legalNameDisplay: LegalNameDisplay;
legalContactEmail: LegalContactEmail; legalContactEmail: LegalContactEmail;
} }
@@ -93,9 +90,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
const settingsResult = await updateBusinessSettingsAction({ const settingsResult = await updateBusinessSettingsAction({
cancellationPolicy: form.cancellationPolicy || undefined, cancellationPolicy: form.cancellationPolicy || undefined,
lateArrivalPolicy: form.lateArrivalPolicy || undefined,
bookingTerms: form.bookingTerms || undefined,
privacyPolicyUrl: form.privacyPolicyUrl || undefined,
legalNameDisplay: form.legalNameDisplay, legalNameDisplay: form.legalNameDisplay,
legalContactEmail: form.legalContactEmail, legalContactEmail: form.legalContactEmail,
}); });
@@ -219,19 +213,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
value={form.cancellationPolicy} value={form.cancellationPolicy}
onChange={(e) => set("cancellationPolicy", e.target.value)} onChange={(e) => set("cancellationPolicy", e.target.value)}
/> />
<Textarea
label="Late Arrival Policy"
value={form.lateArrivalPolicy}
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
/>
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
<Input
label="Privacy Policy URL"
type="url"
placeholder="https://"
value={form.privacyPolicyUrl}
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
/>
<div className="border-line space-y-4 border-t pt-4"> <div className="border-line space-y-4 border-t pt-4">
<p className="text-muted ml-1 text-xs leading-relaxed"> <p className="text-muted ml-1 text-xs leading-relaxed">
Your site&apos;s{" "} Your site&apos;s{" "}
@@ -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<string, unknown>) => 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<string, Handler> = {};
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<string, unknown> = {
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<string, unknown>) => {
const pending: Promise<unknown>[] = [];
handlers[type]!({ ...event, waitUntil: (p: Promise<unknown>) => 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();
});
});
@@ -2,6 +2,21 @@
import { useEffect, useState } from "react"; 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<void> };
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 * Registers the service worker once, app-wide, and surfaces the
* update-available handshake public/sw.js sets up: a new SW version * update-available handshake public/sw.js sets up: a new SW version
@@ -17,6 +32,12 @@ export function ServiceWorkerManager() {
useEffect(() => { useEffect(() => {
if (!("serviceWorker" in navigator)) return; if (!("serviceWorker" in navigator)) return;
const onVisibilityChange = () => {
if (document.visibilityState === "visible") clearAppBadgeAndNotifications();
};
onVisibilityChange();
document.addEventListener("visibilitychange", onVisibilityChange);
navigator.serviceWorker navigator.serviceWorker
.register("/sw.js") .register("/sw.js")
.then((registration) => { .then((registration) => {
@@ -48,7 +69,10 @@ export function ServiceWorkerManager() {
window.location.reload(); window.location.reload();
}; };
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange); 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; if (!waitingWorker || dismissed) return null;
@@ -4,8 +4,13 @@ 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, NotificationType } from "@/generated/prisma/enums"; 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<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 {
@@ -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 { RewardType } from "@/generated/prisma/enums";
import { addStamp, awardStampForCompletedAppointment, redeemReward } from "../loyalty.service"; import { addStamp, awardStampForCompletedAppointment, redeemReward } from "../loyalty.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures"; 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 * channel/preference logic is covered elsewhere; this file is "does the
* loyalty workflow call notify() correctly, exactly once per real event". * 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)", () => { describe("loyalty notifications (integration)", () => {
let business: TestBusiness; let business: TestBusiness;
@@ -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]);
});
});
@@ -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 { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { import {
cleanupTestBusiness, cleanupTestBusiness,
@@ -20,6 +20,10 @@ import {
setNotificationPreference, setNotificationPreference,
} from "../notification.service"; } 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)", () => { describe("NotificationService (integration)", () => {
let business: TestBusiness; let business: TestBusiness;
let guestProfileIds: string[]; let guestProfileIds: string[];
+9
View File
@@ -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;
@@ -1,6 +1,7 @@
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client"; import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums"; import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "./in-app";
import { defaultPreferenceIsEnabled, isPreferenceControlled } from "./types"; 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 // 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. // in-app entries a single booking used to always create for them.
if ( if (
IN_APP_NOTIFICATIONS_VISIBLE &&
params.recipientUserId && params.recipientUserId &&
(await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.IN_APP })) (await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.IN_APP }))
) { ) {
@@ -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 { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time"; import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } 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 * is covered by notification.service.integration.test.ts; this file is
* specifically "does the booking workflow call notify() correctly". * 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)", () => { describe("booking notifications (integration)", () => {
let business: TestBusiness; let business: TestBusiness;
let monday: CalendarDate; let monday: CalendarDate;
@@ -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 { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures"; import type { TestBusiness } from "./fixtures";
import { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums"; 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 * Every scheduled IN_APP copy is created SCHEDULED, not SENT, so it stays
* out of the notification bell until the worker releases it. * 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)", () => { describe("reminder scheduling (integration)", () => {
let business: TestBusiness; let business: TestBusiness;
let monday: CalendarDate; let monday: CalendarDate;