diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 7539e76a..4c95109e 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -10,7 +10,11 @@ import { Wallet, Table2, LayoutList, + RotateCcw, } from "lucide-react" +import { useNotificationPreferences } from "@/hooks/useNotificationPreferences" +import type { NotificationCategoryKey, NotificationChannelKey, NotificationIntensity } from "@/types/notification-preferences" +import { usePrivacy } from "@/context/PrivacyContext" import { Alert, AlertDescription } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" @@ -34,7 +38,6 @@ import { cn } from "@/lib/utils" type TimeFormat = "local-12h" | "local-24h" | "utc" type CurrencyDisplay = "usd" | "usdc" | "both" -type NotificationIntensity = "important" | "balanced" | "everything" const timeFormatOptions: Array<{ value: TimeFormat @@ -106,18 +109,25 @@ export default function SettingsPage() { // Density from global hook const { density, setDensity, tokens: densityTokensCurrent } = useDensity() + // Notification preferences hook (isolated per wallet account) + const { + preferences: notifPrefs, + activeAccount, + isDefault: isDefaultNotifPrefs, + updatePreferences: updateNotifPrefs, + setCategoryEnabled, + setChannelEnabled, + setIntensity: setNotifIntensity, + resetPreferences: resetNotifPrefs, + } = useNotificationPreferences() + const [timeFormat, setTimeFormat] = useState("local-24h") const [currencyDisplay, setCurrencyDisplay] = useState("both") - const [notificationPreset, setNotificationPreset] = useState("important") const [reduceMotion, setReduceMotion] = useState(false) const [showNetPayouts, setShowNetPayouts] = useState(true) const [showWalletBadge, setShowWalletBadge] = useState(true) const [publicActivity, setPublicActivity] = useState(false) - const [disputeAlerts, setDisputeAlerts] = useState(true) - const [oracleDelayAlerts, setOracleDelayAlerts] = useState(true) - const [priceMovementAlerts, setPriceMovementAlerts] = useState(false) - const [weeklyDigest, setWeeklyDigest] = useState(true) - const { hideBalances, setHideBalances } = usePrivacy(); + const { hideBalances, setHideBalances } = usePrivacy() const [walletAlias, setWalletAlias] = useState(true) const [copyWarning, setCopyWarning] = useState(true) const { soundEnabled, setSoundEnabled } = useSoundEnabled() @@ -127,8 +137,8 @@ export default function SettingsPage() { [density] ) const selectedPreset = useMemo( - () => notificationPresets.find((option) => option.value === notificationPreset), - [notificationPreset] + () => notificationPresets.find((option) => option.value === notifPrefs.intensity), + [notifPrefs.intensity] ) const handleSave = (event: SyntheticEvent) => { @@ -326,12 +336,12 @@ export default function SettingsPage() {
{notificationPresets.map((preset) => { - const active = notificationPreset === preset.value + const active = notifPrefs.intensity === preset.value return (
@@ -511,49 +521,116 @@ export default function SettingsPage() { - - Notification settings - Manage your notification preferences. + +
+
+ + Notification Settings + + + Account: {activeAccount === "anonymous" ? "Default (Anonymous)" : `${activeAccount.slice(0, 6)}...${activeAccount.slice(-4)}`} + + {isDefaultNotifPrefs ? ( + + Explicit defaults active + + ) : null} +
+ +
+ Notification preferences + + Configure deterministic, account-isolated notification preferences for settlement, market, and wallet events. +
- - - - - Safe default: activity sharing is off until you explicitly choose to make it visible. - - - -
- - - - + + {/* Category Controls */} +
+ +
+ setCategoryEnabled("settlement", val)} + /> + setCategoryEnabled("market", val)} + /> + setCategoryEnabled("wallet", val)} + /> + setCategoryEnabled("dispute", val)} + /> + setCategoryEnabled("system", val)} + /> +
+
+ + + + {/* Delivery Channels */} +
+ +
+ setChannelEnabled("inApp", val)} + /> + setChannelEnabled("push", val)} + /> + setChannelEnabled("email", val)} + /> +
-

Privacy guidance

+

Deterministic handling & account isolation

    -
  • Use a separate wallet if public market participation should stay distinct from your main identity.
  • -
  • Keep profile activity off unless you want disputes, payouts, and voting behavior to be discoverable.
  • -
  • Prefer aliases in shared screenshots so addresses are not accidentally exposed outside the product.
  • +
  • Preferences are strictly scoped to the active wallet account ({activeAccount === "anonymous" ? "anonymous" : activeAccount}). Switching wallets automatically swaps preferences without cross-contamination.
  • +
  • Offline changes are queued and reconciled deterministically with server state using Last-Write-Wins timestamps.
  • +
  • Explicit defaults ensure predictable notifications for all new and reset accounts.
@@ -602,7 +679,6 @@ function PreferenceSwitch({ ) } -import { usePrivacy } from '@/context/PrivacyContext'; function PreferenceSelect({ id, label, diff --git a/app/state/__tests__/notificationPreferences.test.ts b/app/state/__tests__/notificationPreferences.test.ts new file mode 100644 index 00000000..615dc8ee --- /dev/null +++ b/app/state/__tests__/notificationPreferences.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for useNotificationPreferencesStore (Zustand store). + * Covers account isolation, account switching, explicit defaults, + * reset functionality, offline queue, conflict reconciliation, and persistence. + */ + +import { act } from "@testing-library/react"; +import { useNotificationPreferencesStore } from "../notificationPreferences"; +import { + DEFAULT_ACCOUNT, + NOTIFICATION_PREFERENCES_STORAGE_KEY, + getDefaultNotificationPreferences, +} from "@/lib/notification-preferences"; + +describe("useNotificationPreferencesStore", () => { + beforeEach(() => { + localStorage.clear(); + act(() => { + useNotificationPreferencesStore.getState().resetAllAccounts(); + }); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe("Account Isolation & Switching", () => { + it("isolates preferences between different accounts without cross-contamination", () => { + const accountA = "0xAccountA"; + const accountB = "0xAccountB"; + + // Select Account A and modify its preferences + act(() => { + useNotificationPreferencesStore.getState().setActiveAccount(accountA); + useNotificationPreferencesStore.getState().setCategoryEnabled("market", false, accountA); + useNotificationPreferencesStore.getState().setIntensity("everything", accountA); + }); + + expect(useNotificationPreferencesStore.getState().getPreferences(accountA).categories.market).toBe(false); + expect(useNotificationPreferencesStore.getState().getPreferences(accountA).intensity).toBe("everything"); + + // Switch to Account B (should receive fresh explicit defaults) + act(() => { + useNotificationPreferencesStore.getState().setActiveAccount(accountB); + }); + + const prefsB = useNotificationPreferencesStore.getState().getPreferences(accountB); + expect(prefsB.account).toBe("0xaccountb"); + expect(prefsB.categories.market).toBe(true); // Account B defaults intact + expect(prefsB.intensity).toBe("important"); // Account B defaults intact + + // Modify Account B + act(() => { + useNotificationPreferencesStore.getState().setCategoryEnabled("wallet", false, accountB); + }); + + // Switch back to Account A and verify A's preferences are preserved untouched + act(() => { + useNotificationPreferencesStore.getState().setActiveAccount(accountA); + }); + + const prefsA = useNotificationPreferencesStore.getState().getPreferences(accountA); + expect(prefsA.categories.market).toBe(false); + expect(prefsA.categories.wallet).toBe(true); // Account A's wallet setting was not changed by Account B + expect(prefsA.intensity).toBe("everything"); + }); + }); + + describe("Explicit Defaults & Reset", () => { + it("provides explicit defaults for unconfigured accounts", () => { + const prefs = useNotificationPreferencesStore.getState().getPreferences("0xNewUser"); + const defaults = getDefaultNotificationPreferences("0xNewUser"); + + expect(prefs.account).toBe("0xnewuser"); + expect(prefs.intensity).toBe(defaults.intensity); + expect(prefs.categories).toEqual(defaults.categories); + expect(prefs.channels).toEqual(defaults.channels); + }); + + it("resets an account's preferences back to explicit defaults", () => { + const account = "0xUserToReset"; + + act(() => { + useNotificationPreferencesStore.getState().setActiveAccount(account); + useNotificationPreferencesStore.getState().setIntensity("everything", account); + useNotificationPreferencesStore.getState().setCategoryEnabled("settlement", false, account); + useNotificationPreferencesStore.getState().setChannelEnabled("email", true, account); + }); + + expect(useNotificationPreferencesStore.getState().getPreferences(account).intensity).toBe("everything"); + expect(useNotificationPreferencesStore.getState().getPreferences(account).categories.settlement).toBe(false); + + // Perform reset + act(() => { + useNotificationPreferencesStore.getState().resetPreferences(account); + }); + + const resetPrefs = useNotificationPreferencesStore.getState().getPreferences(account); + const defaults = getDefaultNotificationPreferences(account); + + expect(resetPrefs.intensity).toBe("important"); + expect(resetPrefs.categories.settlement).toBe(true); + expect(resetPrefs.channels.email).toBe(false); + expect(resetPrefs.categories).toEqual(defaults.categories); + }); + }); + + describe("Offline Queue & Server Reconciliation", () => { + it("records offline changes in offline queue when offline", () => { + const account = "0xOfflineUser"; + + act(() => { + useNotificationPreferencesStore.getState().setActiveAccount(account); + useNotificationPreferencesStore.getState().setOnline(false); + useNotificationPreferencesStore.getState().setCategoryEnabled("market", false, account); + }); + + const state = useNotificationPreferencesStore.getState(); + expect(state.isOnline).toBe(false); + expect(state.offlineQueue.length).toBeGreaterThan(0); + expect(state.offlineQueue[0].account).toBe("0xofflineuser"); + expect(state.offlineQueue[0].changes).toEqual( + expect.objectContaining({ + categories: expect.objectContaining({ market: false }), + }) + ); + }); + + it("reconciles server state and flushes resolved offline mutations", () => { + const account = "0xSyncUser"; + + // Setup offline queue mutation + act(() => { + useNotificationPreferencesStore.getState().setActiveAccount(account); + useNotificationPreferencesStore.getState().setOnline(false); + useNotificationPreferencesStore.getState().setCategoryEnabled("dispute", false, account); + }); + + expect(useNotificationPreferencesStore.getState().offlineQueue).toHaveLength(1); + + // Server returns remote state + const serverPrefs = { + ...getDefaultNotificationPreferences(account), + version: 10, + updatedAt: Date.now() + 5000, + intensity: "balanced" as const, + }; + + act(() => { + useNotificationPreferencesStore.getState().reconcileWithServer({ + account, + preferences: serverPrefs, + version: 10, + updatedAt: Date.now() + 5000, + }); + }); + + const updatedPrefs = useNotificationPreferencesStore.getState().getPreferences(account); + expect(updatedPrefs.intensity).toBe("balanced"); + // Offline queue should now be empty after reconciliation + expect(useNotificationPreferencesStore.getState().offlineQueue).toHaveLength(0); + }); + }); + + describe("Persistence and Storage", () => { + it("persists preferences across storage reloads", () => { + const account = "0xPersistUser"; + + act(() => { + useNotificationPreferencesStore.getState().setActiveAccount(account); + useNotificationPreferencesStore.getState().setIntensity("everything", account); + }); + + const storedRaw = localStorage.getItem(NOTIFICATION_PREFERENCES_STORAGE_KEY); + expect(storedRaw).toBeTruthy(); + const parsed = JSON.parse(storedRaw ?? "{}"); + expect(parsed.state.preferencesByAccount["0xpersistuser"].intensity).toBe("everything"); + }); + }); +}); diff --git a/app/state/__tests__/notifications.test.ts b/app/state/__tests__/notifications.test.ts index bca6a5b2..8a9ea52b 100644 --- a/app/state/__tests__/notifications.test.ts +++ b/app/state/__tests__/notifications.test.ts @@ -193,6 +193,8 @@ describe("useNotificationsStore", () => { makeNotification("second"), ]); }); +}); + const item = (overrides: Partial = {}): NotificationItem => ({ id: "notification-1", userId: "current-user", diff --git a/app/state/notificationPreferences.ts b/app/state/notificationPreferences.ts new file mode 100644 index 00000000..9bafd6a6 --- /dev/null +++ b/app/state/notificationPreferences.ts @@ -0,0 +1,438 @@ +/** + * notificationPreferences.ts + * + * Scoped, deterministic notification preference store. + * Supports per-account isolation, offline mutation queueing, + * conflict reconciliation, and cross-tab synchronization. + */ + +import { create } from "zustand"; +import { persist, type StorageValue } from "zustand/middleware"; +import { + NotificationPreferences, + NotificationCategoryKey, + NotificationChannelKey, + NotificationIntensity, + OfflinePreferenceMutation, + ServerPreferencePayload, + ReconciliationResult, +} from "@/types/notification-preferences"; +import { NotificationItem } from "@/types/notifications"; +import { + DEFAULT_ACCOUNT, + NOTIFICATION_PREFERENCES_STORAGE_KEY, + NOTIFICATION_PREFERENCES_EVENT, + getDefaultNotificationPreferences, + normalizeAccount, + normalizeNotificationPreferences, + mergePreferences, + enqueueOfflineMutation as libEnqueueMutation, + reconcilePreferences as libReconcile, + shouldDeliverNotification, + arePreferencesEqual, + clonePreferences, +} from "@/lib/notification-preferences"; + +export interface NotificationPreferencesState { + activeAccount: string; + preferencesByAccount: Record; + offlineQueue: OfflinePreferenceMutation[]; + isOnline: boolean; + syncStatus: "idle" | "syncing" | "synced" | "error" | "offline"; + + // Account management + setActiveAccount: (account: string | null) => void; + getPreferences: (account?: string | null) => NotificationPreferences; + + // Granular preference updates + updatePreferences: ( + changes: + | Partial> + | ((prev: NotificationPreferences) => Partial), + account?: string | null + ) => void; + setCategoryEnabled: ( + category: NotificationCategoryKey, + enabled: boolean, + account?: string | null + ) => void; + setChannelEnabled: ( + channel: NotificationChannelKey, + enabled: boolean, + account?: string | null + ) => void; + setIntensity: ( + intensity: NotificationIntensity, + account?: string | null + ) => void; + + // Reset controls + resetPreferences: (account?: string | null) => void; + resetAllAccounts: () => void; + + // Offline queue & server reconciliation + setOnline: (online: boolean) => void; + enqueueOfflineMutation: ( + mutation: Omit + ) => void; + reconcileWithServer: (serverPayload: ServerPreferencePayload) => ReconciliationResult; + clearOfflineQueue: (account?: string | null) => void; + + // Deterministic notification evaluation + shouldReceiveNotification: ( + notification: Partial & { category: string; severity?: string }, + account?: string | null, + now?: Date + ) => boolean; +} + +function parsePersistedState( + raw: string | null +): StorageValue | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || !parsed.state) return null; + + const state = parsed.state; + const rawPrefs = (state.preferencesByAccount && typeof state.preferencesByAccount === "object") + ? state.preferencesByAccount + : {}; + + const normalizedMap: Record = {}; + for (const [accKey, rawVal] of Object.entries(rawPrefs)) { + const normAcc = normalizeAccount(accKey); + normalizedMap[normAcc] = normalizeNotificationPreferences(rawVal, normAcc); + } + + const activeAccount = normalizeAccount(state.activeAccount); + if (!normalizedMap[activeAccount]) { + normalizedMap[activeAccount] = getDefaultNotificationPreferences(activeAccount); + } + + const offlineQueue = Array.isArray(state.offlineQueue) ? state.offlineQueue : []; + + return { + state: { + activeAccount, + preferencesByAccount: normalizedMap, + offlineQueue, + isOnline: typeof state.isOnline === "boolean" ? state.isOnline : true, + syncStatus: "idle", + } as NotificationPreferencesState, + version: parsed.version ?? 0, + }; + } catch { + return null; + } +} + +export const useNotificationPreferencesStore = create()( + persist( + (set, get) => ({ + activeAccount: DEFAULT_ACCOUNT, + preferencesByAccount: { + [DEFAULT_ACCOUNT]: getDefaultNotificationPreferences(DEFAULT_ACCOUNT), + }, + offlineQueue: [], + isOnline: typeof navigator !== "undefined" ? navigator.onLine : true, + syncStatus: "idle", + + setActiveAccount: (rawAccount) => { + const account = normalizeAccount(rawAccount); + const current = get().preferencesByAccount[account]; + if (current) { + set({ activeAccount: account }); + } else { + const fresh = getDefaultNotificationPreferences(account); + set((state) => ({ + activeAccount: account, + preferencesByAccount: { + ...state.preferencesByAccount, + [account]: fresh, + }, + })); + } + }, + + getPreferences: (rawAccount) => { + const account = normalizeAccount(rawAccount ?? get().activeAccount); + const map = get().preferencesByAccount; + if (map[account]) { + return map[account]; + } + return getDefaultNotificationPreferences(account); + }, + + updatePreferences: (changesOrFn, rawAccount) => { + const account = normalizeAccount(rawAccount ?? get().activeAccount); + const current = get().getPreferences(account); + const diff = typeof changesOrFn === "function" ? changesOrFn(current) : changesOrFn; + + const updated = mergePreferences(current, diff); + const isOnline = get().isOnline; + + set((state) => { + let nextQueue = state.offlineQueue; + if (!isOnline) { + const mutation: OfflinePreferenceMutation = { + id: `mut-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + account, + changes: diff, + timestamp: updated.updatedAt, + version: updated.version, + }; + nextQueue = libEnqueueMutation(state.offlineQueue, mutation); + } + + return { + preferencesByAccount: { + ...state.preferencesByAccount, + [account]: updated, + }, + offlineQueue: nextQueue, + syncStatus: isOnline ? "synced" : "offline", + }; + }); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(NOTIFICATION_PREFERENCES_EVENT, { + detail: { account, preferences: updated }, + }) + ); + } + }, + + setCategoryEnabled: (category, enabled, rawAccount) => { + const account = normalizeAccount(rawAccount ?? get().activeAccount); + get().updatePreferences( + (prev) => ({ + categories: { + ...prev.categories, + [category]: enabled, + }, + }), + account + ); + }, + + setChannelEnabled: (channel, enabled, rawAccount) => { + const account = normalizeAccount(rawAccount ?? get().activeAccount); + get().updatePreferences( + (prev) => ({ + channels: { + ...prev.channels, + [channel]: enabled, + }, + }), + account + ); + }, + + setIntensity: (intensity, rawAccount) => { + const account = normalizeAccount(rawAccount ?? get().activeAccount); + get().updatePreferences({ intensity }, account); + }, + + resetPreferences: (rawAccount) => { + const account = normalizeAccount(rawAccount ?? get().activeAccount); + const fresh = getDefaultNotificationPreferences(account); + + set((state) => ({ + preferencesByAccount: { + ...state.preferencesByAccount, + [account]: fresh, + }, + offlineQueue: state.offlineQueue.filter( + (m) => normalizeAccount(m.account) !== account + ), + syncStatus: "idle", + })); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(NOTIFICATION_PREFERENCES_EVENT, { + detail: { account, preferences: fresh }, + }) + ); + } + }, + + resetAllAccounts: () => { + const fresh = getDefaultNotificationPreferences(DEFAULT_ACCOUNT); + set({ + activeAccount: DEFAULT_ACCOUNT, + preferencesByAccount: { + [DEFAULT_ACCOUNT]: fresh, + }, + offlineQueue: [], + syncStatus: "idle", + }); + }, + + setOnline: (online) => { + set({ + isOnline: online, + syncStatus: online ? (get().offlineQueue.length > 0 ? "syncing" : "synced") : "offline", + }); + }, + + enqueueOfflineMutation: (mutationInput) => { + const account = normalizeAccount(mutationInput.account); + const mutation: OfflinePreferenceMutation = { + id: `mut-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + account, + changes: mutationInput.changes, + timestamp: Date.now(), + version: get().getPreferences(account).version + 1, + }; + + set((state) => ({ + offlineQueue: libEnqueueMutation(state.offlineQueue, mutation), + syncStatus: "offline", + })); + }, + + reconcileWithServer: (serverPayload: ServerPreferencePayload) => { + const account = normalizeAccount(serverPayload.account); + const clientCurrent = get().getPreferences(account); + const currentQueue = get().offlineQueue; + + const reconciliation = libReconcile( + clientCurrent, + serverPayload.preferences, + currentQueue + ); + + const resolvedIds = new Set(reconciliation.resolvedMutationIds); + const remainingQueue = currentQueue.filter((m) => !resolvedIds.has(m.id)); + + set((state) => ({ + preferencesByAccount: { + ...state.preferencesByAccount, + [account]: reconciliation.preferences, + }, + offlineQueue: remainingQueue, + syncStatus: "synced", + })); + + return reconciliation; + }, + + clearOfflineQueue: (rawAccount) => { + if (!rawAccount) { + set({ offlineQueue: [] }); + return; + } + const account = normalizeAccount(rawAccount); + set((state) => ({ + offlineQueue: state.offlineQueue.filter( + (m) => normalizeAccount(m.account) !== account + ), + })); + }, + + shouldReceiveNotification: (notification, rawAccount, now) => { + const account = normalizeAccount(rawAccount ?? get().activeAccount); + const prefs = get().getPreferences(account); + const decision = shouldDeliverNotification( + { + category: notification.category, + userId: notification.userId, + account, + title: notification.title, + severity: notification.severity, + }, + prefs, + { now: now ?? new Date() } + ); + return decision.allowed; + }, + }), + { + name: NOTIFICATION_PREFERENCES_STORAGE_KEY, + storage: { + getItem: (key) => { + try { + return parsePersistedState(localStorage.getItem(key)); + } catch { + return null; + } + }, + setItem: (key, value) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Gracefully handle storage quota or private mode issues + } + }, + removeItem: (key) => { + try { + localStorage.removeItem(key); + } catch { + // Fail silently + } + }, + }, + merge: (persistedState, currentState) => { + if (!persistedState || typeof persistedState !== "object") { + return currentState; + } + const casted = persistedState as Partial; + return { + ...currentState, + ...casted, + preferencesByAccount: { + ...currentState.preferencesByAccount, + ...(casted.preferencesByAccount ?? {}), + }, + offlineQueue: Array.isArray(casted.offlineQueue) + ? casted.offlineQueue + : currentState.offlineQueue, + }; + }, + } + ) +); + +// Cross-tab storage synchronization +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key !== NOTIFICATION_PREFERENCES_STORAGE_KEY) return; + const parsed = parsePersistedState(event.newValue); + if (!parsed) return; + + const currentStore = useNotificationPreferencesStore.getState(); + const newMap = parsed.state.preferencesByAccount; + + if (newMap && typeof newMap === "object") { + let hasChanges = false; + for (const [acc, prefs] of Object.entries(newMap)) { + const currentPrefs = currentStore.preferencesByAccount[acc]; + if (!currentPrefs || !arePreferencesEqual(currentPrefs, prefs)) { + hasChanges = true; + break; + } + } + + if (hasChanges) { + useNotificationPreferencesStore.setState({ + preferencesByAccount: { + ...currentStore.preferencesByAccount, + ...newMap, + }, + offlineQueue: parsed.state.offlineQueue ?? currentStore.offlineQueue, + }); + } + } + }); + + // Online / offline listeners + window.addEventListener("online", () => { + useNotificationPreferencesStore.getState().setOnline(true); + }); + window.addEventListener("offline", () => { + useNotificationPreferencesStore.getState().setOnline(false); + }); +} diff --git a/hooks/__tests__/useNotificationPreferences.test.tsx b/hooks/__tests__/useNotificationPreferences.test.tsx new file mode 100644 index 00000000..af4b1df7 --- /dev/null +++ b/hooks/__tests__/useNotificationPreferences.test.tsx @@ -0,0 +1,94 @@ +/** + * Tests for useNotificationPreferences React hook. + */ + +jest.mock("@creit.tech/stellar-wallets-kit", () => ({ + ALBEDO_ID: "albedo", + FREIGHTER_ID: "freighter", + LOBSTR_ID: "lobstr", + RABET_ID: "rabet", + XBULL_ID: "xbull", +})); + +import React from "react"; +import { renderHook, act } from "@testing-library/react"; +import { useNotificationPreferences } from "../useNotificationPreferences"; +import { useNotificationPreferencesStore } from "@/app/state/notificationPreferences"; +import { WalletProvider, useWalletContext } from "@/context/WalletContext"; + +describe("useNotificationPreferences Hook", () => { + beforeEach(() => { + localStorage.clear(); + act(() => { + useNotificationPreferencesStore.getState().resetAllAccounts(); + }); + }); + + it("returns default preferences for anonymous account when disconnected", () => { + const { result } = renderHook(() => useNotificationPreferences("0xAnonUser")); + + expect(result.current.activeAccount).toBe("0xanonuser"); + expect(result.current.preferences.intensity).toBe("important"); + expect(result.current.isDefault).toBe(true); + }); + + it("updates category preference and marks isDefault as false", () => { + const { result } = renderHook(() => useNotificationPreferences("0xTestUser")); + + act(() => { + result.current.setCategoryEnabled("settlement", false); + }); + + expect(result.current.preferences.categories.settlement).toBe(false); + expect(result.current.isDefault).toBe(false); + }); + + it("resets preferences to defaults upon resetPreferences call", () => { + const { result } = renderHook(() => useNotificationPreferences("0xResetUser")); + + act(() => { + result.current.setIntensity("everything"); + result.current.setChannelEnabled("email", true); + }); + + expect(result.current.preferences.intensity).toBe("everything"); + expect(result.current.preferences.channels.email).toBe(true); + expect(result.current.isDefault).toBe(false); + + act(() => { + result.current.resetPreferences(); + }); + + expect(result.current.preferences.intensity).toBe("important"); + expect(result.current.preferences.channels.email).toBe(false); + expect(result.current.isDefault).toBe(true); + }); + + it("dynamically switches preferences when active account changes", () => { + let currentAccount = "0xUser1"; + const { result, rerender } = renderHook( + ({ account }) => useNotificationPreferences(account), + { initialProps: { account: currentAccount } } + ); + + // Modify User 1 + act(() => { + result.current.setCategoryEnabled("market", false); + }); + expect(result.current.preferences.categories.market).toBe(false); + + // Switch to User 2 + currentAccount = "0xUser2"; + rerender({ account: currentAccount }); + + expect(result.current.activeAccount).toBe("0xuser2"); + expect(result.current.preferences.categories.market).toBe(true); // User 2 has defaults + + // Switch back to User 1 + currentAccount = "0xUser1"; + rerender({ account: currentAccount }); + + expect(result.current.activeAccount).toBe("0xuser1"); + expect(result.current.preferences.categories.market).toBe(false); // User 1 preserved + }); +}); diff --git a/hooks/useNotificationPreferences.ts b/hooks/useNotificationPreferences.ts new file mode 100644 index 00000000..1ef9beb6 --- /dev/null +++ b/hooks/useNotificationPreferences.ts @@ -0,0 +1,168 @@ +"use client"; + +import { useEffect, useMemo, useCallback } from "react"; +import { + NotificationPreferences, + NotificationCategoryKey, + NotificationChannelKey, + NotificationIntensity, + ServerPreferencePayload, + ReconciliationResult, +} from "@/types/notification-preferences"; +import { NotificationItem } from "@/types/notifications"; +import { useNotificationPreferencesStore } from "@/app/state/notificationPreferences"; +import { useWalletContext } from "@/context/WalletContext"; +import { + arePreferencesEqual, + getDefaultNotificationPreferences, + normalizeAccount, +} from "@/lib/notification-preferences"; + +export interface UseNotificationPreferencesReturn { + preferences: NotificationPreferences; + activeAccount: string; + isOnline: boolean; + syncStatus: "idle" | "syncing" | "synced" | "error" | "offline"; + isDefault: boolean; + + updatePreferences: ( + changes: + | Partial> + | ((prev: NotificationPreferences) => Partial) + ) => void; + setCategoryEnabled: (category: NotificationCategoryKey, enabled: boolean) => void; + setChannelEnabled: (channel: NotificationChannelKey, enabled: boolean) => void; + setIntensity: (intensity: NotificationIntensity) => void; + resetPreferences: () => void; + reconcileWithServer: (payload: ServerPreferencePayload) => ReconciliationResult; + shouldReceiveNotification: ( + notification: Partial & { category: string; severity?: string }, + now?: Date + ) => boolean; +} + +/** + * Hook for consuming and modifying account-isolated notification preferences. + * Automatically synchronizes with the connected wallet address. + */ +export function useNotificationPreferences( + explicitAccount?: string | null +): UseNotificationPreferencesReturn { + let walletAddress: string | null = null; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks + const wallet = useWalletContext(); + walletAddress = wallet.address; + } catch { + // WalletProvider may not be present in standalone tests or preview pages + } + + const effectiveAccount = useMemo(() => { + if (explicitAccount !== undefined) { + return normalizeAccount(explicitAccount); + } + return normalizeAccount(walletAddress); + }, [explicitAccount, walletAddress]); + + const activeAccount = useNotificationPreferencesStore((s) => s.activeAccount); + const setActiveAccount = useNotificationPreferencesStore((s) => s.setActiveAccount); + const preferencesByAccount = useNotificationPreferencesStore((s) => s.preferencesByAccount); + const isOnline = useNotificationPreferencesStore((s) => s.isOnline); + const syncStatus = useNotificationPreferencesStore((s) => s.syncStatus); + const storeUpdatePreferences = useNotificationPreferencesStore((s) => s.updatePreferences); + const storeSetCategoryEnabled = useNotificationPreferencesStore((s) => s.setCategoryEnabled); + const storeSetChannelEnabled = useNotificationPreferencesStore((s) => s.setChannelEnabled); + const storeSetIntensity = useNotificationPreferencesStore((s) => s.setIntensity); + const storeResetPreferences = useNotificationPreferencesStore((s) => s.resetPreferences); + const storeReconcileWithServer = useNotificationPreferencesStore((s) => s.reconcileWithServer); + const storeShouldReceiveNotification = useNotificationPreferencesStore( + (s) => s.shouldReceiveNotification + ); + + // Keep store's activeAccount in sync with effectiveAccount + useEffect(() => { + if (effectiveAccount !== activeAccount) { + setActiveAccount(effectiveAccount); + } + }, [effectiveAccount, activeAccount, setActiveAccount]); + + const preferences = useMemo(() => { + return ( + preferencesByAccount[effectiveAccount] ?? + getDefaultNotificationPreferences(effectiveAccount) + ); + }, [preferencesByAccount, effectiveAccount]); + + const isDefault = useMemo(() => { + const defaultPrefs = getDefaultNotificationPreferences(effectiveAccount); + return arePreferencesEqual(preferences, defaultPrefs); + }, [preferences, effectiveAccount]); + + const updatePreferences = useCallback( + ( + changes: + | Partial> + | ((prev: NotificationPreferences) => Partial) + ) => { + storeUpdatePreferences(changes, effectiveAccount); + }, + [storeUpdatePreferences, effectiveAccount] + ); + + const setCategoryEnabled = useCallback( + (category: NotificationCategoryKey, enabled: boolean) => { + storeSetCategoryEnabled(category, enabled, effectiveAccount); + }, + [storeSetCategoryEnabled, effectiveAccount] + ); + + const setChannelEnabled = useCallback( + (channel: NotificationChannelKey, enabled: boolean) => { + storeSetChannelEnabled(channel, enabled, effectiveAccount); + }, + [storeSetChannelEnabled, effectiveAccount] + ); + + const setIntensity = useCallback( + (intensity: NotificationIntensity) => { + storeSetIntensity(intensity, effectiveAccount); + }, + [storeSetIntensity, effectiveAccount] + ); + + const resetPreferences = useCallback(() => { + storeResetPreferences(effectiveAccount); + }, [storeResetPreferences, effectiveAccount]); + + const reconcileWithServer = useCallback( + (payload: ServerPreferencePayload) => { + return storeReconcileWithServer(payload); + }, + [storeReconcileWithServer] + ); + + const shouldReceiveNotification = useCallback( + ( + notification: Partial & { category: string; severity?: string }, + now?: Date + ) => { + return storeShouldReceiveNotification(notification, effectiveAccount, now); + }, + [storeShouldReceiveNotification, effectiveAccount] + ); + + return { + preferences, + activeAccount: effectiveAccount, + isOnline, + syncStatus, + isDefault, + updatePreferences, + setCategoryEnabled, + setChannelEnabled, + setIntensity, + resetPreferences, + reconcileWithServer, + shouldReceiveNotification, + }; +} diff --git a/lib/__tests__/notification-preferences.test.ts b/lib/__tests__/notification-preferences.test.ts new file mode 100644 index 00000000..2885ff2b --- /dev/null +++ b/lib/__tests__/notification-preferences.test.ts @@ -0,0 +1,309 @@ +/** + * Tests for deterministic notification preferences library logic. + */ + +import { + DEFAULT_ACCOUNT, + DEFAULT_CATEGORY_PREFERENCES, + DEFAULT_CHANNEL_PREFERENCES, + EXPLICIT_DEFAULT_NOTIFICATION_PREFERENCES, + normalizeAccount, + getDefaultNotificationPreferences, + normalizeNotificationPreferences, + clonePreferences, + arePreferencesEqual, + mergePreferences, + enqueueOfflineMutation, + applyOfflineMutations, + reconcilePreferences, + shouldDeliverNotification, +} from "../notification-preferences"; +import { + NotificationPreferences, + OfflinePreferenceMutation, +} from "@/types/notification-preferences"; + +describe("Deterministic Notification Preferences Library", () => { + describe("normalizeAccount", () => { + it("returns default account for null, undefined, or empty strings", () => { + expect(normalizeAccount(null)).toBe(DEFAULT_ACCOUNT); + expect(normalizeAccount(undefined)).toBe(DEFAULT_ACCOUNT); + expect(normalizeAccount("")).toBe(DEFAULT_ACCOUNT); + expect(normalizeAccount(" ")).toBe(DEFAULT_ACCOUNT); + }); + + it("normalizes case and trims whitespace", () => { + expect(normalizeAccount(" 0xABCDEF123456 ")).toBe("0xabcdef123456"); + expect(normalizeAccount("GA1234567890BCDEF")).toBe("ga1234567890bcdef"); + }); + }); + + describe("getDefaultNotificationPreferences", () => { + it("returns complete, explicit defaults for any account", () => { + const prefs = getDefaultNotificationPreferences("0xuser1"); + expect(prefs.account).toBe("0xuser1"); + expect(prefs.version).toBe(1); + expect(prefs.intensity).toBe("important"); + expect(prefs.categories).toEqual(DEFAULT_CATEGORY_PREFERENCES); + expect(prefs.channels).toEqual(DEFAULT_CHANNEL_PREFERENCES); + expect(prefs.disputeAlerts).toBe(true); + expect(prefs.oracleDelayAlerts).toBe(true); + expect(prefs.priceMovementAlerts).toBe(false); + expect(prefs.weeklyDigest).toBe(true); + expect(prefs.showNetPayouts).toBe(true); + }); + + it("returns fresh object instances that do not mutate defaults", () => { + const prefs1 = getDefaultNotificationPreferences("acc1"); + const prefs2 = getDefaultNotificationPreferences("acc2"); + prefs1.categories.market = false; + expect(prefs2.categories.market).toBe(true); + expect(EXPLICIT_DEFAULT_NOTIFICATION_PREFERENCES.categories.market).toBe(true); + }); + }); + + describe("normalizeNotificationPreferences", () => { + it("safely fills missing or corrupt properties with explicit defaults", () => { + const partial = { + account: "0xTest", + categories: { market: false }, + intensity: "balanced", + }; + const normalized = normalizeNotificationPreferences(partial); + expect(normalized.account).toBe("0xtest"); + expect(normalized.intensity).toBe("balanced"); + expect(normalized.categories.market).toBe(false); + expect(normalized.categories.settlement).toBe(true); // default filled + expect(normalized.channels.inApp).toBe(true); // default filled + expect(normalized.disputeAlerts).toBe(true); // default filled + }); + + it("handles non-object inputs gracefully", () => { + expect(normalizeNotificationPreferences(null)).toEqual( + expect.objectContaining({ account: DEFAULT_ACCOUNT, version: 1 }) + ); + expect(normalizeNotificationPreferences("invalid")).toEqual( + expect.objectContaining({ account: DEFAULT_ACCOUNT, version: 1 }) + ); + }); + }); + + describe("arePreferencesEqual and clonePreferences", () => { + it("accurately detects identical and divergent preferences", () => { + const p1 = getDefaultNotificationPreferences("user1"); + const p2 = getDefaultNotificationPreferences("user1"); + expect(arePreferencesEqual(p1, p2)).toBe(true); + + const p3 = clonePreferences(p1); + p3.categories.wallet = false; + expect(arePreferencesEqual(p1, p3)).toBe(false); + }); + }); + + describe("mergePreferences", () => { + it("increments version and updates timestamp on mutation", () => { + const base = getDefaultNotificationPreferences("user1"); + const updated = mergePreferences(base, { + intensity: "everything", + categories: { ...base.categories, priceMovementAlerts: false as any, market: false }, + }); + + expect(updated.version).toBe(base.version + 1); + expect(updated.intensity).toBe("everything"); + expect(updated.categories.market).toBe(false); + expect(updated.categories.settlement).toBe(true); + }); + }); + + describe("Offline Mutation Queue & Reconciliation", () => { + it("enqueues mutations idempotently without duplicate IDs", () => { + let queue: OfflinePreferenceMutation[] = []; + const mutation1: OfflinePreferenceMutation = { + id: "mut-1", + account: "0xAlice", + changes: { intensity: "everything" }, + timestamp: 1000, + version: 2, + }; + const mutation1Updated: OfflinePreferenceMutation = { + id: "mut-1", + account: "0xAlice", + changes: { intensity: "balanced" }, + timestamp: 1100, + version: 2, + }; + + queue = enqueueOfflineMutation(queue, mutation1); + expect(queue).toHaveLength(1); + expect(queue[0].changes.intensity).toBe("everything"); + + // Re-enqueuing same ID updates in place without duplicating + queue = enqueueOfflineMutation(queue, mutation1Updated); + expect(queue).toHaveLength(1); + expect(queue[0].changes.intensity).toBe("balanced"); + }); + + it("applies offline mutations sequentially in timestamp order", () => { + const base = getDefaultNotificationPreferences("0xAlice"); + const mutations: OfflinePreferenceMutation[] = [ + { + id: "mut-2", + account: "0xAlice", + changes: { categories: { ...base.categories, wallet: false } }, + timestamp: 2000, + version: 3, + }, + { + id: "mut-1", + account: "0xAlice", + changes: { intensity: "balanced" }, + timestamp: 1000, + version: 2, + }, + ]; + + const result = applyOfflineMutations(base, mutations); + expect(result.intensity).toBe("balanced"); + expect(result.categories.wallet).toBe(false); + expect(result.account).toBe("0xalice"); + }); + + it("reconciles server state and client offline mutations deterministically (Conflict Resolution)", () => { + const clientBase = { + ...getDefaultNotificationPreferences("0xAlice"), + version: 1, + updatedAt: 1000, + }; + + // Server was updated remotely at timestamp 1500 to change intensity to 'balanced' + const serverState: NotificationPreferences = { + ...clientBase, + version: 2, + updatedAt: 1500, + intensity: "balanced", + }; + + // Client has an offline mutation recorded at timestamp 2000 to disable wallet alerts + const offlineMutations: OfflinePreferenceMutation[] = [ + { + id: "mut-offline-1", + account: "0xAlice", + changes: { categories: { ...clientBase.categories, wallet: false } }, + timestamp: 2000, + version: 3, + }, + ]; + + const reconciliation = reconcilePreferences(clientBase, serverState, offlineMutations); + + // Reconciled result combines server updates and newer client offline mutations + expect(reconciliation.preferences.intensity).toBe("balanced"); // from server + expect(reconciliation.preferences.categories.wallet).toBe(false); // from offline mutation + expect(reconciliation.hasConflicts).toBe(true); + expect(reconciliation.resolvedMutationIds).toContain("mut-offline-1"); + expect(reconciliation.preferences.version).toBeGreaterThanOrEqual(3); + }); + + it("does not overwrite newer server changes with stale offline mutations", () => { + const clientBase = { + ...getDefaultNotificationPreferences("0xAlice"), + version: 1, + updatedAt: 1000, + }; + + // Stale mutation recorded offline at timestamp 1100 + const staleMutation: OfflinePreferenceMutation = { + id: "mut-stale", + account: "0xAlice", + changes: { intensity: "everything" }, + timestamp: 1100, + version: 1, + }; + + // Server was updated at timestamp 2500 to 'important' + const serverState: NotificationPreferences = { + ...clientBase, + version: 5, + updatedAt: 2500, + intensity: "important", + }; + + const reconciliation = reconcilePreferences(clientBase, serverState, [staleMutation]); + expect(reconciliation.preferences.intensity).toBe("important"); // Server wins because it is newer + expect(reconciliation.resolvedMutationIds).toContain("mut-stale"); + }); + }); + + describe("shouldDeliverNotification (Deterministic Filtering)", () => { + const defaultPrefs = getDefaultNotificationPreferences("0xUser"); + + it("allows settlement/payout notifications by default", () => { + const decision = shouldDeliverNotification( + { category: "payout", userId: "0xUser", title: "Claim ready" }, + defaultPrefs + ); + expect(decision.allowed).toBe(true); + }); + + it("blocks notification if category is disabled", () => { + const customPrefs = mergePreferences(defaultPrefs, { + categories: { ...defaultPrefs.categories, market: false }, + }); + + const decision = shouldDeliverNotification( + { category: "market", userId: "0xUser", title: "Market closing" }, + customPrefs + ); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("category_disabled"); + }); + + it("filters low-signal market notifications under 'important' intensity preset", () => { + const decision = shouldDeliverNotification( + { category: "market", userId: "0xUser", title: "Market closing" }, + defaultPrefs // intensity: 'important' + ); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("intensity_filtered"); + }); + + it("allows market notifications when intensity is 'balanced' or 'everything'", () => { + const balancedPrefs = mergePreferences(defaultPrefs, { intensity: "balanced" }); + const decision = shouldDeliverNotification( + { category: "market", userId: "0xUser", title: "Market closing" }, + balancedPrefs + ); + expect(decision.allowed).toBe(true); + }); + + it("suppresses non-critical notifications during quiet hours", () => { + const quietPrefs = mergePreferences(defaultPrefs, { + intensity: "everything", + quietHours: { enabled: true, start: "00:00", end: "23:59", tz: "auto" }, + }); + + const nonCritical = shouldDeliverNotification( + { category: "market", userId: "0xUser", severity: "info" }, + quietPrefs + ); + expect(nonCritical.allowed).toBe(false); + expect(nonCritical.reason).toBe("quiet_hours_active"); + + // Critical dispute or warning alerts still pass through quiet hours + const critical = shouldDeliverNotification( + { category: "dispute", userId: "0xUser", severity: "critical" }, + quietPrefs + ); + expect(critical.allowed).toBe(true); + }); + + it("rejects notifications targeted at another account", () => { + const decision = shouldDeliverNotification( + { category: "settlement", userId: "0xOtherUser", title: "Settled" }, + defaultPrefs + ); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("account_mismatch"); + }); + }); +}); diff --git a/lib/notification-preferences.ts b/lib/notification-preferences.ts new file mode 100644 index 00000000..c05c13a6 --- /dev/null +++ b/lib/notification-preferences.ts @@ -0,0 +1,474 @@ +/** + * notification-preferences.ts + * + * Deterministic notification preference management, account isolation, + * offline mutation queueing, and conflict resolution. + */ + +import { + NotificationPreferences, + NotificationCategoryKey, + NotificationChannelKey, + NotificationIntensity, + QuietHoursPreference, + OfflinePreferenceMutation, + ReconciliationResult, + NotificationFilterDecision, + CategoryPreferences, + ChannelPreferences, +} from "@/types/notification-preferences"; +import { isQuietHoursActive } from "@/lib/quiet-hours"; + +export const DEFAULT_ACCOUNT = "anonymous"; +export const NOTIFICATION_PREFERENCES_STORAGE_KEY = "predictify_notification_preferences_v1"; +export const NOTIFICATION_PREFERENCES_EVENT = "predictify:notification-preferences-changed"; + +export const DEFAULT_CATEGORY_PREFERENCES: Readonly = Object.freeze({ + settlement: true, + market: true, + wallet: true, + dispute: true, + payout: true, + system: true, + account: true, +}); + +export const DEFAULT_CHANNEL_PREFERENCES: Readonly = Object.freeze({ + inApp: true, + email: false, + push: false, +}); + +export const DEFAULT_QUIET_HOURS_PREFERENCE: Readonly = Object.freeze({ + enabled: false, + start: "22:00", + end: "08:00", + tz: "auto", +}); + +export const EXPLICIT_DEFAULT_NOTIFICATION_PREFERENCES: Readonly = Object.freeze({ + account: DEFAULT_ACCOUNT, + version: 1, + updatedAt: 0, + intensity: "important" as NotificationIntensity, + categories: { ...DEFAULT_CATEGORY_PREFERENCES }, + channels: { ...DEFAULT_CHANNEL_PREFERENCES }, + quietHours: { ...DEFAULT_QUIET_HOURS_PREFERENCE }, + disputeAlerts: true, + oracleDelayAlerts: true, + priceMovementAlerts: false, + weeklyDigest: true, + showNetPayouts: true, +}); + +/** + * Normalizes account identifier: lowercase, trimmed, with 'anonymous' as fallback. + */ +export function normalizeAccount(account?: string | null): string { + if (!account || typeof account !== "string") { + return DEFAULT_ACCOUNT; + } + const trimmed = account.trim().toLowerCase(); + return trimmed.length > 0 ? trimmed : DEFAULT_ACCOUNT; +} + +/** + * Produces an explicit default NotificationPreferences record for an account. + */ +export function getDefaultNotificationPreferences(account?: string | null): NotificationPreferences { + const normAccount = normalizeAccount(account); + return { + account: normAccount, + version: 1, + updatedAt: Date.now(), + intensity: "important", + categories: { ...DEFAULT_CATEGORY_PREFERENCES }, + channels: { ...DEFAULT_CHANNEL_PREFERENCES }, + quietHours: { ...DEFAULT_QUIET_HOURS_PREFERENCE }, + disputeAlerts: true, + oracleDelayAlerts: true, + priceMovementAlerts: false, + weeklyDigest: true, + showNetPayouts: true, + }; +} + +/** + * Deep clones a preferences object. + */ +export function clonePreferences(prefs: NotificationPreferences): NotificationPreferences { + return { + ...prefs, + categories: { ...prefs.categories }, + channels: { ...prefs.channels }, + quietHours: { ...prefs.quietHours }, + }; +} + +/** + * Defensively validates and normalizes an untrusted or partially populated preferences object. + */ +export function normalizeNotificationPreferences( + raw: unknown, + fallbackAccount?: string | null +): NotificationPreferences { + const targetAccount = normalizeAccount( + raw && typeof raw === "object" && "account" in raw + ? (raw as { account?: unknown }).account as string + : fallbackAccount + ); + + const defaults = getDefaultNotificationPreferences(targetAccount); + if (!raw || typeof raw !== "object") { + return defaults; + } + + const obj = raw as Record; + + // Normalize intensity + let intensity: NotificationIntensity = defaults.intensity; + if ( + obj.intensity === "important" || + obj.intensity === "balanced" || + obj.intensity === "everything" + ) { + intensity = obj.intensity; + } + + // Normalize categories + const categories: CategoryPreferences = { ...DEFAULT_CATEGORY_PREFERENCES }; + if (obj.categories && typeof obj.categories === "object") { + const rawCat = obj.categories as Record; + for (const key of Object.keys(DEFAULT_CATEGORY_PREFERENCES) as NotificationCategoryKey[]) { + if (typeof rawCat[key] === "boolean") { + categories[key] = rawCat[key] as boolean; + } + } + } + + // Normalize channels + const channels: ChannelPreferences = { ...DEFAULT_CHANNEL_PREFERENCES }; + if (obj.channels && typeof obj.channels === "object") { + const rawChan = obj.channels as Record; + for (const key of Object.keys(DEFAULT_CHANNEL_PREFERENCES) as NotificationChannelKey[]) { + if (typeof rawChan[key] === "boolean") { + channels[key] = rawChan[key] as boolean; + } + } + } + + // Normalize quiet hours + const quietHours: QuietHoursPreference = { ...DEFAULT_QUIET_HOURS_PREFERENCE }; + if (obj.quietHours && typeof obj.quietHours === "object") { + const rawQ = obj.quietHours as Record; + if (typeof rawQ.enabled === "boolean") quietHours.enabled = rawQ.enabled; + if (typeof rawQ.start === "string" && rawQ.start.length > 0) quietHours.start = rawQ.start; + if (typeof rawQ.end === "string" && rawQ.end.length > 0) quietHours.end = rawQ.end; + if (typeof rawQ.tz === "string" && rawQ.tz.length > 0) quietHours.tz = rawQ.tz; + } + + const version = typeof obj.version === "number" && Number.isFinite(obj.version) && obj.version > 0 + ? obj.version + : 1; + + const updatedAt = typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) + ? obj.updatedAt + : Date.now(); + + return { + account: targetAccount, + version, + updatedAt, + intensity, + categories, + channels, + quietHours, + disputeAlerts: typeof obj.disputeAlerts === "boolean" ? obj.disputeAlerts : defaults.disputeAlerts, + oracleDelayAlerts: typeof obj.oracleDelayAlerts === "boolean" ? obj.oracleDelayAlerts : defaults.oracleDelayAlerts, + priceMovementAlerts: typeof obj.priceMovementAlerts === "boolean" ? obj.priceMovementAlerts : defaults.priceMovementAlerts, + weeklyDigest: typeof obj.weeklyDigest === "boolean" ? obj.weeklyDigest : defaults.weeklyDigest, + showNetPayouts: typeof obj.showNetPayouts === "boolean" ? obj.showNetPayouts : defaults.showNetPayouts, + }; +} + +/** + * Checks equality between two NotificationPreferences objects. + */ +export function arePreferencesEqual( + a: NotificationPreferences, + b: NotificationPreferences +): boolean { + if (a.account !== b.account) return false; + if (a.intensity !== b.intensity) return false; + if (a.disputeAlerts !== b.disputeAlerts) return false; + if (a.oracleDelayAlerts !== b.oracleDelayAlerts) return false; + if (a.priceMovementAlerts !== b.priceMovementAlerts) return false; + if (a.weeklyDigest !== b.weeklyDigest) return false; + if (a.showNetPayouts !== b.showNetPayouts) return false; + + for (const k of Object.keys(DEFAULT_CATEGORY_PREFERENCES) as NotificationCategoryKey[]) { + if (a.categories[k] !== b.categories[k]) return false; + } + + for (const k of Object.keys(DEFAULT_CHANNEL_PREFERENCES) as NotificationChannelKey[]) { + if (a.channels[k] !== b.channels[k]) return false; + } + + if ( + a.quietHours.enabled !== b.quietHours.enabled || + a.quietHours.start !== b.quietHours.start || + a.quietHours.end !== b.quietHours.end || + a.quietHours.tz !== b.quietHours.tz + ) { + return false; + } + + return true; +} + +/** + * Applies partial changes to a base preferences object and bumps the updatedAt & version. + */ +export function mergePreferences( + base: NotificationPreferences, + changes: Partial> +): NotificationPreferences { + const cloned = clonePreferences(base); + + if (changes.intensity) cloned.intensity = changes.intensity; + if (changes.categories) { + cloned.categories = { ...cloned.categories, ...changes.categories }; + } + if (changes.channels) { + cloned.channels = { ...cloned.channels, ...changes.channels }; + } + if (changes.quietHours) { + cloned.quietHours = { ...cloned.quietHours, ...changes.quietHours }; + } + if (typeof changes.disputeAlerts === "boolean") cloned.disputeAlerts = changes.disputeAlerts; + if (typeof changes.oracleDelayAlerts === "boolean") cloned.oracleDelayAlerts = changes.oracleDelayAlerts; + if (typeof changes.priceMovementAlerts === "boolean") cloned.priceMovementAlerts = changes.priceMovementAlerts; + if (typeof changes.weeklyDigest === "boolean") cloned.weeklyDigest = changes.weeklyDigest; + if (typeof changes.showNetPayouts === "boolean") cloned.showNetPayouts = changes.showNetPayouts; + + cloned.version = (base.version || 1) + 1; + cloned.updatedAt = typeof changes.updatedAt === "number" ? changes.updatedAt : Date.now(); + + return cloned; +} + +/** + * Enqueues an offline mutation idempotently without duplicate IDs. + */ +export function enqueueOfflineMutation( + queue: OfflinePreferenceMutation[], + mutation: OfflinePreferenceMutation +): OfflinePreferenceMutation[] { + const normAccount = normalizeAccount(mutation.account); + const sanitizedMutation: OfflinePreferenceMutation = { + ...mutation, + account: normAccount, + }; + + const existingIndex = queue.findIndex((m) => m.id === sanitizedMutation.id); + if (existingIndex >= 0) { + const nextQueue = [...queue]; + nextQueue[existingIndex] = sanitizedMutation; + return nextQueue; + } + + return [...queue, sanitizedMutation]; +} + +/** + * Deterministically applies a series of offline mutations to a base preferences object. + */ +export function applyOfflineMutations( + base: NotificationPreferences, + mutations: OfflinePreferenceMutation[] +): NotificationPreferences { + const targetAccount = normalizeAccount(base.account); + const relevantMutations = mutations + .filter((m) => normalizeAccount(m.account) === targetAccount) + .sort((a, b) => a.timestamp - b.timestamp); + + let current = clonePreferences(base); + for (const mutation of relevantMutations) { + current = mergePreferences(current, { + ...mutation.changes, + updatedAt: mutation.timestamp, + }); + } + + return current; +} + +/** + * Reconciles local client preferences, server preferences, and pending offline mutations. + * Guarantees deterministic conflict resolution with Last-Write-Wins (LWW). + */ +export function reconcilePreferences( + clientPrefs: NotificationPreferences, + serverPrefs: NotificationPreferences, + offlineMutations: OfflinePreferenceMutation[] = [] +): ReconciliationResult { + const normClient = normalizeNotificationPreferences(clientPrefs); + const normServer = normalizeNotificationPreferences(serverPrefs, normClient.account); + const account = normClient.account; + + const relevantMutations = offlineMutations + .filter((m) => normalizeAccount(m.account) === account) + .sort((a, b) => a.timestamp - b.timestamp); + + const resolvedMutationIds: string[] = []; + let hasConflicts = false; + let appliedChangesCount = 0; + + // Base preference resolution: pick the newer base or server if versions differ + let reconciled: NotificationPreferences; + + if (normServer.version > normClient.version || normServer.updatedAt > normClient.updatedAt) { + hasConflicts = true; + reconciled = clonePreferences(normServer); + } else if (normClient.version > normServer.version || normClient.updatedAt > normServer.updatedAt) { + reconciled = clonePreferences(normClient); + } else { + // Versions match; prefer server values for deterministic convergence + reconciled = clonePreferences(normServer); + } + + // Now apply pending offline mutations if they are newer than the resolved base timestamp + for (const mutation of relevantMutations) { + if (mutation.timestamp >= reconciled.updatedAt || mutation.version >= reconciled.version) { + reconciled = mergePreferences(reconciled, { + ...mutation.changes, + updatedAt: Math.max(mutation.timestamp, Date.now()), + }); + appliedChangesCount++; + } + resolvedMutationIds.push(mutation.id); + } + + // Ensure account, monotonic version, and updatedAt are consistent + reconciled.account = account; + reconciled.version = Math.max(normClient.version, normServer.version) + (appliedChangesCount > 0 ? 1 : 0); + + return { + preferences: reconciled, + resolvedMutationIds, + hasConflicts, + appliedChangesCount, + }; +} + +/** + * Evaluates whether an incoming notification should be delivered based on account preferences. + */ +export function shouldDeliverNotification( + notification: { + category: string; + userId?: string; + account?: string; + title?: string; + severity?: string; + variant?: string | null; + }, + preferences: NotificationPreferences, + options?: { + now?: Date; + channel?: NotificationChannelKey; + } +): NotificationFilterDecision { + const normTarget = normalizeAccount(preferences.account); + const notifAccount = notification.account || notification.userId; + + // 1. Account scoping check (if specific account is provided) + if (notifAccount && notifAccount !== "current-user" && normalizeAccount(notifAccount) !== normTarget) { + return { allowed: false, reason: "account_mismatch" }; + } + + // 2. Channel check (if specified) + const channel = options?.channel ?? "inApp"; + if (preferences.channels[channel] === false) { + return { allowed: false, reason: "channel_disabled" }; + } + + // 3. Category mapping & toggle check + const cat = notification.category.toLowerCase(); + let mappedCategory: NotificationCategoryKey = "system"; + + if (cat === "settlement" || cat === "payout") { + mappedCategory = preferences.categories.settlement ? "settlement" : "payout"; + if (!preferences.categories.settlement && !preferences.categories.payout) { + return { allowed: false, reason: "category_disabled" }; + } + } else if (cat === "market") { + mappedCategory = "market"; + if (!preferences.categories.market) { + return { allowed: false, reason: "category_disabled" }; + } + } else if (cat === "wallet") { + mappedCategory = "wallet"; + if (!preferences.categories.wallet) { + return { allowed: false, reason: "category_disabled" }; + } + } else if (cat === "dispute") { + mappedCategory = "dispute"; + if (!preferences.categories.dispute || !preferences.disputeAlerts) { + return { allowed: false, reason: "category_disabled" }; + } + } else if (cat === "account") { + mappedCategory = "account"; + if (!preferences.categories.account) { + return { allowed: false, reason: "category_disabled" }; + } + } else if (cat === "system") { + mappedCategory = "system"; + if (!preferences.categories.system) { + return { allowed: false, reason: "category_disabled" }; + } + } + + // 4. Intensity level filter + if (preferences.intensity === "important") { + // Important intensity only allows high-signal events: settlement/payout, dispute, wallet errors/warnings, critical system + const isHighSignal = + mappedCategory === "settlement" || + mappedCategory === "payout" || + mappedCategory === "dispute" || + mappedCategory === "wallet" || + notification.severity === "critical" || + notification.severity === "warning" || + notification.variant === "destructive"; + + if (!isHighSignal && mappedCategory === "market") { + return { allowed: false, reason: "intensity_filtered" }; + } + } + + // 5. Quiet Hours check + if (preferences.quietHours.enabled) { + const isQuiet = isQuietHoursActive( + { + start: preferences.quietHours.start, + end: preferences.quietHours.end, + tz: preferences.quietHours.tz, + }, + options?.now ?? new Date() + ); + + if (isQuiet) { + const isCritical = + notification.severity === "critical" || + notification.severity === "warning" || + notification.variant === "destructive" || + mappedCategory === "dispute"; + + if (!isCritical) { + return { allowed: false, reason: "quiet_hours_active" }; + } + } + } + + return { allowed: true, reason: "allowed" }; +} diff --git a/types/notification-preferences.ts b/types/notification-preferences.ts new file mode 100644 index 00000000..eb8e6811 --- /dev/null +++ b/types/notification-preferences.ts @@ -0,0 +1,91 @@ +/** + * Notification Preference Types + * + * Defines the types and data structures for deterministic, per-account + * notification preferences, conflict resolution, and offline reconciliation. + */ + +export type NotificationCategoryKey = + | "settlement" + | "market" + | "wallet" + | "dispute" + | "payout" + | "system" + | "account"; + +export type NotificationChannelKey = "inApp" | "email" | "push"; + +export type NotificationIntensity = "important" | "balanced" | "everything"; + +export interface QuietHoursPreference { + enabled: boolean; + start: string; // e.g. "22:00" + end: string; // e.g. "08:00" + tz: "auto" | "UTC" | string; +} + +export type CategoryPreferences = Record; +export type ChannelPreferences = Record; + +export interface NotificationPreferences { + /** The account/wallet address to which these preferences belong (normalized lowercase or 'anonymous') */ + account: string; + /** Monotonically increasing schema/data version for conflict detection */ + version: number; + /** Unix timestamp in milliseconds of the last modification */ + updatedAt: number; + /** Preset intensity for signal-to-noise control */ + intensity: NotificationIntensity; + /** Granular category toggles */ + categories: CategoryPreferences; + /** Delivery channel toggles */ + channels: ChannelPreferences; + /** Quiet hours configuration */ + quietHours: QuietHoursPreference; + /** Specific feature alert flags */ + disputeAlerts: boolean; + oracleDelayAlerts: boolean; + priceMovementAlerts: boolean; + weeklyDigest: boolean; + showNetPayouts: boolean; +} + +export interface OfflinePreferenceMutation { + /** Unique mutation ID for idempotency and deduplication */ + id: string; + /** Target account address */ + account: string; + /** Partial updates to apply */ + changes: Partial; + /** Timestamp when the mutation was recorded offline */ + timestamp: number; + /** Local version at the time of mutation */ + version: number; +} + +export interface ServerPreferencePayload { + account: string; + preferences: NotificationPreferences; + version: number; + updatedAt: number; +} + +export interface ReconciliationResult { + preferences: NotificationPreferences; + resolvedMutationIds: string[]; + hasConflicts: boolean; + appliedChangesCount: number; +} + +export interface NotificationFilterDecision { + allowed: boolean; + reason?: + | "allowed" + | "account_mismatch" + | "category_disabled" + | "channel_disabled" + | "intensity_filtered" + | "quiet_hours_active" + | "feature_alert_disabled"; +} diff --git a/types/notifications.ts b/types/notifications.ts index 262ee7d0..54ec5a92 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -31,3 +31,6 @@ export interface NotificationDigestData { isLoading: boolean error?: string | null } + +export * from "./notification-preferences" +