Email Notifictions
CI / ci (push) Canceled after 0s

Core logic: types.ts (defaults), notification.service.ts (skipEmail + dedup), booking-notifications.ts, loyalty-notifications.ts, reminder-scheduling.ts, booking.service.ts + scheduling.service.ts (staff-selection capture), features/owner/actions/appointments.ts.
Schema: prisma/schema.prisma + new migration 20260912165903_add_staff_selected_by_customer.
UI: both notification-preferences pages (customer, staff) now use the same default logic as the actual send decision.
Tests: notification.service.integration.test.ts, booking-notifications.integration.test.ts, reminder-scheduling.integration.test.ts, loyalty-notifications.integration.test.ts, plus a new createTestAppointment fixture.
Docs: docs/notifications.md rewritten to match.

Default changes

Exactly as specified — full table is in docs/notifications.md. In short: STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, OWNER_NO_SHOW now default off; everything else that was already toggleable is untouched.

No longer email (fixed, not a toggle)

BOOKING_COMPLETED, LOYALTY_STAMP_ADDED, LOYALTY_REWARD_REDEEMED — always. BOOKING_BARBER_CHANGED — only when the customer originally booked "any available barber." The customer's 2-hour reminder is removed entirely (24h stays); staff keeps both, just off by default. All via a new skipEmail flag on notify(), checked in one place — no new preference rows for these.

Deduplication

One new EQUIVALENCE_GROUPS map in notification.service.ts (STAFF_NEW_APPOINTMENT>OWNER_NEW_BOOKING, STAFF_APPOINTMENT_CANCELLED>OWNER_BOOKING_CANCELLED). Inside notify(), before creating deliveries, it checks for an already-created equivalent notification for the same (businessId, appointmentId, recipientUserId); if found and at-least-as-relevant, the new one gets zero deliveries on every channel (row still created for audit trail). Only listed types participate, so OWNER_NO_SHOW and any future actionable owner type are never touched.
This commit is contained in:
Stefan Lazic committed 2026-09-12 18:27:11 +01:00
1 parent 7224c87cef
commit 4573587b78
18 files changed
+999 -172

No files matched your search

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
@@ -197,6 +198,15 @@ describe("NotificationService (integration)", () => {
channel: NotificationChannel.IN_APP,
isEnabled: false,
});
// STAFF_NEW_APPOINTMENT now defaults to off (see types.ts) — explicitly
// enable it here so this test isolates "one type's preference doesn't
// leak into another's", independent of what either defaults to.
await setNotificationPreference(prisma, {
userId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
channel: NotificationChannel.IN_APP,
isEnabled: true,
});
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
@@ -217,6 +227,101 @@ describe("NotificationService (integration)", () => {
expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
});
it("STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, and OWNER_NO_SHOW default to off with no preference row", async () => {
const { userId } = await createTestCustomer(business);
const disabledByDefault = [
NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.STAFF_APPOINTMENT_REMINDER,
NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW,
];
for (const type of disabledByDefault) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:default-off:${type}`,
});
expect(notification.deliveries).toHaveLength(0);
}
});
it("STAFF_APPOINTMENT_CANCELLED, STAFF_SCHEDULE_CHANGED, STAFF_INVITATION_RECEIVED, OWNER_BOOKING_CANCELLED, OWNER_STAFF_INVITATION_ACCEPTED, and OWNER_REPEATED_DELIVERY_FAILURE still default to on with no preference row", async () => {
const { userId } = await createTestCustomer(business);
const stillEnabledByDefault = [
NotificationType.STAFF_APPOINTMENT_CANCELLED,
NotificationType.STAFF_SCHEDULE_CHANGED,
NotificationType.STAFF_INVITATION_RECEIVED,
NotificationType.OWNER_BOOKING_CANCELLED,
NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
NotificationType.OWNER_REPEATED_DELIVERY_FAILURE,
];
for (const type of stillEnabledByDefault) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:default-on:${type}`,
});
expect(notification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
}
});
it("an explicit preference always wins over the new default, in both directions", async () => {
const { userId } = await createTestCustomer(business);
// A type that now defaults OFF, explicitly turned ON.
await setNotificationPreference(prisma, {
userId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
channel: NotificationChannel.EMAIL,
isEnabled: true,
});
// A type that still defaults ON, explicitly turned OFF.
await setNotificationPreference(prisma, {
userId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
channel: NotificationChannel.EMAIL,
isEnabled: false,
});
const { notification: onOverride } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:on-override`,
});
expect(onOverride.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
const { notification: offOverride } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:off-override`,
});
expect(offOverride.deliveries.map((d) => d.channel)).not.toContain(NotificationChannel.EMAIL);
});
it("skipEmail suppresses only EMAIL, never PUSH or IN_APP", async () => {
const { userId } = await createTestCustomer(business);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_COMPLETED,
recipientUserId: userId,
bodyText: "Thanks for visiting!",
idempotencyKey: `test:${userId}:booking-completed-skip-email`,
skipEmail: true,
});
const channels = notification.deliveries.map((d) => d.channel);
expect(channels).not.toContain(NotificationChannel.EMAIL);
expect(channels).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(
@@ -245,14 +350,14 @@ describe("NotificationService (integration)", () => {
const { notification: n1 } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: staffUserId,
bodyText: "New appointment booked.",
idempotencyKey: `test:${staffId}:appt-1`,
});
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: staffUserId,
bodyText: "Another appointment booked.",
idempotencyKey: `test:${staffId}:appt-2`,
@@ -281,4 +386,187 @@ describe("NotificationService (integration)", () => {
expect(unreadOnly).toHaveLength(0);
});
});
/**
* Cross-role deduplication: a solo owner-barber (one recipientUserId
* holding both roles) should get only one equivalent notification per
* booking event — see notification.service.ts's EQUIVALENCE_GROUPS.
* Call order matters (staff before owner, matching every real call site
* in booking-notifications.ts) — see that map's own doc comment.
*/
describe("notify — cross-role deduplication", () => {
// STAFF_NEW_APPOINTMENT and OWNER_NEW_BOOKING both default to off (see
// types.ts) — every test below explicitly enables both so it's
// actually exercising dedup, not just the unrelated default-value
// change (which is covered separately above).
async function enableNewBookingTypes(...userIds: string[]) {
for (const userId of userIds) {
await setNotificationPreference(prisma, { userId, type: NotificationType.STAFF_NEW_APPOINTMENT, channel: NotificationChannel.IN_APP, isEnabled: true });
await setNotificationPreference(prisma, { userId, type: NotificationType.OWNER_NEW_BOOKING, channel: NotificationChannel.IN_APP, isEnabled: true });
}
}
it("a lower-priority OWNER_NEW_BOOKING called after an equivalent STAFF_NEW_APPOINTMENT for the same recipient+appointment is suppressed (zero deliveries, row still created)", async () => {
const staff = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await enableNewBookingTypes(staff.userId);
const { notification: staffNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentId}`,
});
expect(staffNotification.deliveries.length).toBeGreaterThan(0);
// Same person (staff.userId) is also "the owner" here — this is the
// solo owner-barber case the dedup exists for.
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentId}`,
});
expect(ownerNotification.deliveries).toHaveLength(0);
// The row itself still exists (audit trail) — just with no deliveries.
const stillExists = await prisma.notification.findUnique({ where: { id: ownerNotification.id } });
expect(stillExists).not.toBeNull();
});
it("cancellation dedup: STAFF_APPOINTMENT_CANCELLED then OWNER_BOOKING_CANCELLED for the same recipient+appointment — only the staff copy has deliveries", async () => {
const staff = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: staff.userId,
appointmentId,
bodyText: "Cancelled.",
idempotencyKey: `test:dedup:staff-cancelled:${appointmentId}`,
});
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_BOOKING_CANCELLED,
recipientUserId: staff.userId,
appointmentId,
bodyText: "Cancelled.",
idempotencyKey: `test:dedup:owner-cancelled:${appointmentId}`,
});
expect(ownerNotification.deliveries).toHaveLength(0);
});
it("a booking confirmation and a later cancellation for the same appointment are NOT deduped against each other — different events, not equivalent", async () => {
const staff = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
appointmentId,
bodyText: "Confirmed.",
idempotencyKey: `test:dedup:booking-confirmed:${appointmentId}`,
});
const { notification: cancelled } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CANCELLED,
recipientUserId: userId,
recipientCustomerId: customerId,
appointmentId,
bodyText: "Cancelled.",
idempotencyKey: `test:dedup:booking-cancelled:${appointmentId}`,
});
// BOOKING_CONFIRMED/BOOKING_CANCELLED aren't in any equivalence group
// at all, so this is unaffected regardless of order.
expect(cancelled.deliveries.length).toBeGreaterThan(0);
});
it("does not dedup across different appointments, even for the same recipient and equivalent types", async () => {
const staff = await createTestStaff(business);
const { customerId: customerA } = await createTestCustomer(business);
const { customerId: customerB } = await createTestCustomer(business);
const { appointmentId: appointmentA } = await createTestAppointment(business, { customerId: customerA, staffId: staff.staffId });
const { appointmentId: appointmentB } = await createTestAppointment(business, { customerId: customerB, staffId: staff.staffId });
await enableNewBookingTypes(staff.userId);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staff.userId,
appointmentId: appointmentA,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentA}`,
});
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: staff.userId,
appointmentId: appointmentB,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentB}`,
});
expect(ownerNotification.deliveries.length).toBeGreaterThan(0);
});
it("does not dedup across different recipients, even for the same appointment and equivalent types", async () => {
const staffPerson = await createTestStaff(business);
const ownerPerson = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staffPerson.staffId });
await enableNewBookingTypes(staffPerson.userId, ownerPerson.userId);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staffPerson.userId,
appointmentId,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentId}:${staffPerson.userId}`,
});
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: ownerPerson.userId,
appointmentId,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentId}:${ownerPerson.userId}`,
});
expect(ownerNotification.deliveries.length).toBeGreaterThan(0);
});
it("if the lower-priority OWNER_* is somehow created first, a later higher-priority STAFF_* still sends in full (order-inverted case)", async () => {
const staff = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await enableNewBookingTypes(staff.userId);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentId}`,
});
const { notification: staffNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentId}`,
});
expect(staffNotification.deliveries.length).toBeGreaterThan(0);
});
});
});
@@ -1,7 +1,7 @@
import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { isPreferenceControlled } from "./types";
import { defaultPreferenceIsEnabled, isPreferenceControlled } from "./types";
/**
* The notification domain layer: creates Notification + NotificationDelivery
@@ -32,6 +32,17 @@ export interface NotifyParams {
idempotencyKey: string;
/** Omit for "send as soon as the worker picks it up" (every non-reminder type). When set, EMAIL/PUSH deliveries are created directly as SCHEDULED for this time rather than PENDING — see reminder-scheduling.ts, which is the only caller that sets this. */
scheduledFor?: Date;
/**
* When true, EMAIL is never resolved for this call, regardless of any
* stored preference — for a fixed "this no longer emails" policy
* (e.g. BOOKING_COMPLETED, LOYALTY_REWARD_REDEEMED) rather than a
* user-toggleable default. PUSH/IN_APP are unaffected. Deliberately
* separate from the preference system: these types either aren't
* preference-controlled at all, or (BOOKING_BARBER_CHANGED) the
* decision depends on per-booking context a stored preference can't
* express.
*/
skipEmail?: boolean;
}
type NotificationWithDeliveries = Prisma.NotificationGetPayload<{ include: { deliveries: true } }>;
@@ -113,7 +124,10 @@ async function isChannelEnabled(
const preference = await prisma.notificationPreference.findUnique({
where: { userId_type_channel: { userId: params.userId, type: params.type, channel: params.channel } },
});
return preference?.isEnabled ?? true; // sensible default: on unless the user explicitly turned it off
// A stored row always wins, whichever way it's set — only the fallback
// for "nobody has ever touched this" varies by type (see types.ts's
// defaultPreferenceIsEnabled).
return preference?.isEnabled ?? defaultPreferenceIsEnabled(params.type);
}
async function resolveChannels(
@@ -137,7 +151,11 @@ async function resolveChannels(
}
const emailAddress = await resolveEmailAddress(prisma, params);
if (emailAddress && (await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.EMAIL }))) {
if (
emailAddress &&
!params.skipEmail &&
(await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.EMAIL }))
) {
channels.push(NotificationChannel.EMAIL);
}
@@ -151,6 +169,81 @@ async function resolveChannels(
return channels;
}
/**
* Notification types that are two views of the SAME underlying booking
* event, from two different roles — a "new appointment" booked for a
* staff member is the exact same event as "a new booking" for the
* owner. When one person holds both roles for the same appointment
* (a solo owner-barber), they resolve to the identical recipientUserId
* (see recipients.ts's getOwnerUserIds vs appointment.staff.userId), and
* should only ever get ONE of the two — the one more directly tied to
* their actual involvement (higher `priority`), per the product rule
* "deduplicate equivalent information, don't suppress a genuinely
* different actionable purpose". Only types listed here ever participate
* in dedup — e.g. OWNER_NO_SHOW has no staff equivalent by design (the
* barber who marked it already knows) and is deliberately absent, as
* would any future owner-only actionable type (e.g. "approve this
* booking") — nothing about those is touched by this map.
*/
const EQUIVALENCE_GROUPS: Partial<Record<NotificationType, { group: string; priority: number }>> = {
[NotificationType.STAFF_NEW_APPOINTMENT]: { group: "new_appointment", priority: 2 },
[NotificationType.OWNER_NEW_BOOKING]: { group: "new_appointment", priority: 1 },
[NotificationType.STAFF_APPOINTMENT_CANCELLED]: { group: "appointment_cancelled", priority: 2 },
[NotificationType.OWNER_BOOKING_CANCELLED]: { group: "appointment_cancelled", priority: 1 },
};
type EquivalenceOutcome =
| { action: "suppress"; existingType: NotificationType }
| { action: "order-inverted"; existingType: NotificationType };
/**
* Checks for an already-created, equivalent (same group) notification for
* this exact recipient+appointment. Relies on call order: every current
* call site (booking-notifications.ts) already notifies STAFF_* before
* looping OWNER_* for the same event, so by the time an OWNER_* call
* runs, an equivalent STAFF_* row (if any) already exists in the
* database.
*
* - The existing row's priority >= this call's → "suppress": the caller
* should create zero deliveries (still create the Notification row
* itself — keeps idempotencyKey semantics intact and leaves an audit
* trail of "this would have fired").
* - The existing row's priority < this call's → "order-inverted": this
* shouldn't happen given today's call order; proceed normally, but
* this is surfaced so a future call-order regression is at least
* observable rather than silently wrong. Retroactively un-sending an
* already-SENT IN_APP row for a case that can't currently occur would
* be disproportionate — see this file's own design notes.
*/
async function checkEquivalenceGroup(
prisma: PrismaClient,
params: Pick<NotifyParams, "businessId" | "type" | "appointmentId" | "recipientUserId">,
): Promise<EquivalenceOutcome | null> {
const info = EQUIVALENCE_GROUPS[params.type];
if (!info || !params.recipientUserId || !params.appointmentId) return null;
const otherTypesInGroup = (Object.keys(EQUIVALENCE_GROUPS) as NotificationType[]).filter(
(t) => t !== params.type && EQUIVALENCE_GROUPS[t]!.group === info.group,
);
if (otherTypesInGroup.length === 0) return null;
const existing = await prisma.notification.findFirst({
where: {
businessId: params.businessId,
appointmentId: params.appointmentId,
recipientUserId: params.recipientUserId,
type: { in: otherTypesInGroup },
},
select: { type: true },
});
if (!existing) return null;
const existingPriority = EQUIVALENCE_GROUPS[existing.type]!.priority;
return existingPriority >= info.priority
? { action: "suppress", existingType: existing.type }
: { action: "order-inverted", existingType: existing.type };
}
/**
* Creates a Notification and one NotificationDelivery per applicable
* channel — idempotent on `idempotencyKey`: a second call with the same
@@ -170,7 +263,21 @@ export async function notify(prisma: PrismaClient, params: NotifyParams): Promis
});
if (existing) return { created: false, notification: existing };
const channels = await resolveChannels(prisma, params);
let channels = await resolveChannels(prisma, params);
const equivalence = await checkEquivalenceGroup(prisma, params);
if (equivalence?.action === "suppress") {
// Someone already has an equivalent, at-least-as-relevant notification
// for this exact booking (e.g. they're both the owner and the
// assigned staff) — don't duplicate it on any channel. The
// Notification row below is still created (audit trail, consistent
// idempotencyKey behaviour), just with zero deliveries.
channels = [];
} else if (equivalence?.action === "order-inverted") {
console.warn(
`[NotificationService] ${params.type} has higher priority than an existing equivalent ${equivalence.existingType} for the same recipient/appointment, but the earlier notification can't be retroactively suppressed — check call order for this event.`,
);
}
try {
const notification = await prisma.notification.create({
+34
View File
@@ -83,3 +83,37 @@ const PREFERENCE_CONTROLLED_TYPES = new Set<NotificationType>([
export function isPreferenceControlled(type: NotificationType): boolean {
return PREFERENCE_CONTROLLED_TYPES.has(type);
}
/**
* Which preference-controlled types default to OFF when no
* NotificationPreference row exists yet — every other preference-
* controlled type keeps defaulting to on (today's original blanket
* behaviour). A stored row always wins regardless of what's here (see
* NotificationService.isChannelEnabled's `preference?.isEnabled ?? ...`),
* so changing this set never touches anyone's explicit choice — only
* what a brand-new user/business sees before they've ever visited
* Settings. The owner of a business is commonly also its only barber, so
* without softer defaults here a solo owner's very first booking would
* arrive as two emails (STAFF_NEW_APPOINTMENT + OWNER_NEW_BOOKING) they
* never asked for — see also the dedup logic in notification.service.ts,
* which independently prevents a *duplicate* even when both default on.
*/
const DEFAULT_DISABLED_TYPES = new Set<NotificationType>([
NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.STAFF_APPOINTMENT_REMINDER,
NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW,
]);
/**
* The single source of truth for "what does this preference-controlled
* type do when nobody has ever saved a row for it" — used both by the
* actual send decision (isChannelEnabled) and by the Settings pages that
* render each toggle's initial on/off state, so the UI can never show a
* toggle as on for something that actually defaults to off (or vice
* versa). Meaningless for a required (non-preference-controlled) type.
*/
export function defaultPreferenceIsEnabled(type: NotificationType): boolean {
return !DEFAULT_DISABLED_TYPES.has(type);
}