diff --git a/chainhook/.env.example b/chainhook/.env.example
index 39cdb017..85a40024 100644
--- a/chainhook/.env.example
+++ b/chainhook/.env.example
@@ -59,3 +59,11 @@ LOG_LEVEL=INFO
# Metrics Access Control
METRICS_AUTH_TOKEN=
HEALTH_CHECK_ALWAYS_ENABLED=true
+
+# Notification Preferences
+# Preferences are stored per-address in the notification_preferences table.
+# No additional configuration is required; the table is created automatically on startup.
+# API endpoints:
+# GET /api/notifications/preferences/:address
+# PUT /api/notifications/preferences/:address
+# DELETE /api/notifications/preferences/:address
diff --git a/chainhook/migrations/002_add_notification_preferences.sql b/chainhook/migrations/002_add_notification_preferences.sql
new file mode 100644
index 00000000..83ba7a95
--- /dev/null
+++ b/chainhook/migrations/002_add_notification_preferences.sql
@@ -0,0 +1,14 @@
+-- Migration: Add notification preferences table
+-- Issue: #399
+-- Description: Stores per-address notification channel and event-type preferences
+
+CREATE TABLE IF NOT EXISTS notification_preferences (
+ address TEXT PRIMARY KEY,
+ channels JSONB NOT NULL DEFAULT '{"in_app": true, "email": false}',
+ events JSONB NOT NULL DEFAULT '{"tip_received": true, "tip_sent": false, "scheduled_tip_executed": true, "scheduled_tip_failed": true, "refund_requested": true, "refund_resolved": true}',
+ email TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS notification_preferences_address_idx
+ ON notification_preferences (address);
diff --git a/chainhook/notification-preferences-api.test.js b/chainhook/notification-preferences-api.test.js
new file mode 100644
index 00000000..7511e749
--- /dev/null
+++ b/chainhook/notification-preferences-api.test.js
@@ -0,0 +1,205 @@
+import { describe, it, before, after } from "node:test";
+import assert from "node:assert/strict";
+import http from "node:http";
+
+process.env.NODE_ENV = "test";
+process.env.CHAINHOOK_AUTH_TOKEN = "";
+process.env.METRICS_AUTH_TOKEN = "";
+
+const { server } = await import("./server.js");
+
+const VALID_ADDRESS = "SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE";
+const OTHER_ADDRESS = "SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T";
+
+function request(method, path, body) {
+ return new Promise((resolve, reject) => {
+ const payload = body !== undefined ? JSON.stringify(body) : "";
+ const req = http.request(
+ {
+ hostname: "127.0.0.1",
+ port: server.address().port,
+ path,
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(payload),
+ },
+ },
+ (res) => {
+ let data = "";
+ res.on("data", (chunk) => { data += chunk; });
+ res.on("end", () => {
+ try {
+ resolve({ status: res.statusCode, body: JSON.parse(data) });
+ } catch {
+ resolve({ status: res.statusCode, body: data });
+ }
+ });
+ }
+ );
+ req.on("error", reject);
+ if (payload) req.write(payload);
+ req.end();
+ });
+}
+
+before(() => {
+ return new Promise((resolve) => {
+ if (server.listening) return resolve();
+ server.listen(0, "127.0.0.1", resolve);
+ });
+});
+
+after(() => {
+ return new Promise((resolve) => server.close(resolve));
+});
+
+describe("GET /api/notifications/preferences/:address", () => {
+ it("returns default preferences for an address with no stored data", async () => {
+ const res = await request("GET", `/api/notifications/preferences/${VALID_ADDRESS}`);
+ assert.equal(res.status, 200);
+ assert.ok(res.body.preferences);
+ assert.equal(res.body.preferences.address, VALID_ADDRESS);
+ assert.equal(typeof res.body.preferences.channels, "object");
+ assert.equal(typeof res.body.preferences.events, "object");
+ assert.equal(res.body.preferences.channels.in_app, true);
+ assert.equal(res.body.preferences.channels.email, false);
+ assert.equal(res.body.preferences.events.tip_received, true);
+ assert.equal(res.body.preferences.events.tip_sent, false);
+ });
+
+ it("returns 400 for an invalid address format", async () => {
+ const res = await request("GET", "/api/notifications/preferences/not-an-address");
+ assert.equal(res.status, 400);
+ assert.match(res.body.message, /invalid address/i);
+ });
+
+ it("returns 404 for an unrelated route", async () => {
+ const res = await request("GET", "/api/notifications/unknown");
+ assert.equal(res.status, 404);
+ });
+});
+
+describe("PUT /api/notifications/preferences/:address", () => {
+ it("creates preferences and returns 200 with the merged record", async () => {
+ const res = await request("PUT", `/api/notifications/preferences/${OTHER_ADDRESS}`, {
+ channels: { email: true },
+ email: "user@example.com",
+ });
+ assert.equal(res.status, 200);
+ assert.equal(res.body.ok, true);
+ assert.equal(res.body.preferences.channels.email, true);
+ assert.equal(res.body.preferences.email, "user@example.com");
+ });
+
+ it("merges partial updates without overwriting untouched fields", async () => {
+ await request("PUT", `/api/notifications/preferences/${OTHER_ADDRESS}`, {
+ channels: { in_app: false },
+ });
+ const res = await request("GET", `/api/notifications/preferences/${OTHER_ADDRESS}`);
+ assert.equal(res.status, 200);
+ assert.equal(res.body.preferences.channels.in_app, false);
+ assert.equal(res.body.preferences.channels.email, true);
+ });
+
+ it("returns 400 for an invalid address", async () => {
+ const res = await request("PUT", "/api/notifications/preferences/bad-addr", {
+ channels: { in_app: false },
+ });
+ assert.equal(res.status, 400);
+ });
+
+ it("returns 400 for an unknown channel key", async () => {
+ const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ channels: { sms: true },
+ });
+ assert.equal(res.status, 400);
+ assert.match(res.body.message, /unknown channel/i);
+ });
+
+ it("returns 400 for an unknown event type", async () => {
+ const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ events: { mystery_event: true },
+ });
+ assert.equal(res.status, 400);
+ assert.match(res.body.message, /unknown event type/i);
+ });
+
+ it("returns 400 for a malformed email", async () => {
+ const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ email: "not-valid",
+ });
+ assert.equal(res.status, 400);
+ assert.match(res.body.message, /email/i);
+ });
+
+ it("accepts null email to clear the stored address", async () => {
+ await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ email: "clear@example.com",
+ });
+ const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ email: null,
+ });
+ assert.equal(res.status, 200);
+ assert.equal(res.body.preferences.email, null);
+ });
+
+ it("accepts all valid event types toggled", async () => {
+ const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ events: {
+ tip_received: false,
+ tip_sent: true,
+ scheduled_tip_executed: false,
+ scheduled_tip_failed: false,
+ refund_requested: false,
+ refund_resolved: false,
+ },
+ });
+ assert.equal(res.status, 200);
+ assert.equal(res.body.preferences.events.tip_sent, true);
+ assert.equal(res.body.preferences.events.tip_received, false);
+ });
+
+ it("persists preferences so a subsequent GET reflects the update", async () => {
+ await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ channels: { email: true },
+ });
+ const res = await request("GET", `/api/notifications/preferences/${VALID_ADDRESS}`);
+ assert.equal(res.status, 200);
+ assert.equal(res.body.preferences.channels.email, true);
+ });
+});
+
+describe("DELETE /api/notifications/preferences/:address", () => {
+ it("deletes stored preferences and returns deleted: true", async () => {
+ await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ email: "todelete@example.com",
+ });
+ const res = await request("DELETE", `/api/notifications/preferences/${VALID_ADDRESS}`);
+ assert.equal(res.status, 200);
+ assert.equal(res.body.ok, true);
+ assert.equal(res.body.deleted, true);
+ });
+
+ it("returns deleted: false when no preferences were stored for the address", async () => {
+ const fresh = "SP2DEMOADDRESS0000000000000000000000ABCDEF";
+ const res = await request("DELETE", `/api/notifications/preferences/${fresh}`);
+ assert.equal(res.status, 200);
+ assert.equal(res.body.deleted, false);
+ });
+
+ it("returns default preferences after deletion on a subsequent GET", async () => {
+ await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, {
+ channels: { email: true },
+ });
+ await request("DELETE", `/api/notifications/preferences/${VALID_ADDRESS}`);
+ const res = await request("GET", `/api/notifications/preferences/${VALID_ADDRESS}`);
+ assert.equal(res.status, 200);
+ assert.equal(res.body.preferences.channels.email, false);
+ });
+
+ it("returns 400 for an invalid address", async () => {
+ const res = await request("DELETE", "/api/notifications/preferences/bad");
+ assert.equal(res.status, 400);
+ });
+});
diff --git a/chainhook/notification-preferences.js b/chainhook/notification-preferences.js
new file mode 100644
index 00000000..f7705499
--- /dev/null
+++ b/chainhook/notification-preferences.js
@@ -0,0 +1,240 @@
+import { isValidStacksAddress } from "./validation.js";
+
+export const NOTIFICATION_CHANNELS = {
+ IN_APP: "in_app",
+ EMAIL: "email",
+};
+
+export const NOTIFICATION_EVENT_TYPES = {
+ TIP_RECEIVED: "tip_received",
+ TIP_SENT: "tip_sent",
+ SCHEDULED_TIP_EXECUTED: "scheduled_tip_executed",
+ SCHEDULED_TIP_FAILED: "scheduled_tip_failed",
+ REFUND_REQUESTED: "refund_requested",
+ REFUND_RESOLVED: "refund_resolved",
+};
+
+export const DEFAULT_PREFERENCES = {
+ channels: {
+ [NOTIFICATION_CHANNELS.IN_APP]: true,
+ [NOTIFICATION_CHANNELS.EMAIL]: false,
+ },
+ events: {
+ [NOTIFICATION_EVENT_TYPES.TIP_RECEIVED]: true,
+ [NOTIFICATION_EVENT_TYPES.TIP_SENT]: false,
+ [NOTIFICATION_EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: true,
+ [NOTIFICATION_EVENT_TYPES.SCHEDULED_TIP_FAILED]: true,
+ [NOTIFICATION_EVENT_TYPES.REFUND_REQUESTED]: true,
+ [NOTIFICATION_EVENT_TYPES.REFUND_RESOLVED]: true,
+ },
+ email: null,
+};
+
+export function validatePreferencesPayload(body) {
+ if (!body || typeof body !== "object") {
+ return { valid: false, error: "request body must be an object" };
+ }
+
+ if (body.channels !== undefined) {
+ if (typeof body.channels !== "object" || Array.isArray(body.channels)) {
+ return { valid: false, error: "channels must be an object" };
+ }
+ for (const [key, value] of Object.entries(body.channels)) {
+ if (!Object.values(NOTIFICATION_CHANNELS).includes(key)) {
+ return { valid: false, error: `unknown channel: ${key}` };
+ }
+ if (typeof value !== "boolean") {
+ return { valid: false, error: `channel value for '${key}' must be a boolean` };
+ }
+ }
+ }
+
+ if (body.events !== undefined) {
+ if (typeof body.events !== "object" || Array.isArray(body.events)) {
+ return { valid: false, error: "events must be an object" };
+ }
+ for (const [key, value] of Object.entries(body.events)) {
+ if (!Object.values(NOTIFICATION_EVENT_TYPES).includes(key)) {
+ return { valid: false, error: `unknown event type: ${key}` };
+ }
+ if (typeof value !== "boolean") {
+ return { valid: false, error: `event value for '${key}' must be a boolean` };
+ }
+ }
+ }
+
+ if (body.email !== undefined && body.email !== null) {
+ if (typeof body.email !== "string") {
+ return { valid: false, error: "email must be a string or null" };
+ }
+ const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRe.test(body.email)) {
+ return { valid: false, error: "email must be a valid email address" };
+ }
+ if (body.email.length > 254) {
+ return { valid: false, error: "email must be 254 characters or fewer" };
+ }
+ }
+
+ return { valid: true };
+}
+
+export function mergePreferences(existing, updates) {
+ const merged = {
+ channels: { ...existing.channels },
+ events: { ...existing.events },
+ email: existing.email,
+ };
+
+ if (updates.channels) {
+ merged.channels = { ...merged.channels, ...updates.channels };
+ }
+ if (updates.events) {
+ merged.events = { ...merged.events, ...updates.events };
+ }
+ if ("email" in updates) {
+ merged.email = updates.email;
+ }
+
+ return merged;
+}
+
+export class MemoryNotificationPreferencesStore {
+ constructor() {
+ this._store = new Map();
+ }
+
+ async init() {
+ return this;
+ }
+
+ async getPreferences(address) {
+ if (!isValidStacksAddress(address)) {
+ throw new Error("invalid address");
+ }
+ return this._store.get(address) || { ...DEFAULT_PREFERENCES, address };
+ }
+
+ async upsertPreferences(address, preferences) {
+ if (!isValidStacksAddress(address)) {
+ throw new Error("invalid address");
+ }
+ const existing = this._store.get(address) || { ...DEFAULT_PREFERENCES };
+ const merged = mergePreferences(existing, preferences);
+ const record = {
+ ...merged,
+ address,
+ updatedAt: new Date().toISOString(),
+ };
+ this._store.set(address, record);
+ return record;
+ }
+
+ async deletePreferences(address) {
+ if (!isValidStacksAddress(address)) {
+ throw new Error("invalid address");
+ }
+ const existed = this._store.has(address);
+ this._store.delete(address);
+ return { deleted: existed };
+ }
+
+ async close() {}
+}
+
+export class PostgresNotificationPreferencesStore {
+ constructor(pool, retryOptions = {}) {
+ this._pool = pool;
+ this._retryOptions = retryOptions;
+ this._ready = null;
+ }
+
+ async init() {
+ if (!this._ready) {
+ this._ready = this._initialize();
+ }
+ return this._ready;
+ }
+
+ async _initialize() {
+ await this._pool.query(`
+ CREATE TABLE IF NOT EXISTS notification_preferences (
+ address TEXT PRIMARY KEY,
+ channels JSONB NOT NULL DEFAULT '{}',
+ events JSONB NOT NULL DEFAULT '{}',
+ email TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+ `);
+ await this._pool.query(
+ "CREATE INDEX IF NOT EXISTS notification_preferences_address_idx ON notification_preferences (address);"
+ );
+ }
+
+ async getPreferences(address) {
+ if (!isValidStacksAddress(address)) {
+ throw new Error("invalid address");
+ }
+ await this.init();
+ const result = await this._pool.query(
+ "SELECT * FROM notification_preferences WHERE address = $1",
+ [address]
+ );
+ if (!result.rows[0]) {
+ return { ...DEFAULT_PREFERENCES, address };
+ }
+ return this._rowToPreferences(result.rows[0]);
+ }
+
+ async upsertPreferences(address, preferences) {
+ if (!isValidStacksAddress(address)) {
+ throw new Error("invalid address");
+ }
+ await this.init();
+
+ const existing = await this.getPreferences(address);
+ const merged = mergePreferences(existing, preferences);
+
+ const result = await this._pool.query(
+ `INSERT INTO notification_preferences (address, channels, events, email, updated_at)
+ VALUES ($1, $2::jsonb, $3::jsonb, $4, NOW())
+ ON CONFLICT (address) DO UPDATE SET
+ channels = $2::jsonb,
+ events = $3::jsonb,
+ email = $4,
+ updated_at = NOW()
+ RETURNING *`,
+ [
+ address,
+ JSON.stringify(merged.channels),
+ JSON.stringify(merged.events),
+ merged.email || null,
+ ]
+ );
+ return this._rowToPreferences(result.rows[0]);
+ }
+
+ async deletePreferences(address) {
+ if (!isValidStacksAddress(address)) {
+ throw new Error("invalid address");
+ }
+ await this.init();
+ const result = await this._pool.query(
+ "DELETE FROM notification_preferences WHERE address = $1",
+ [address]
+ );
+ return { deleted: result.rowCount > 0 };
+ }
+
+ _rowToPreferences(row) {
+ return {
+ address: row.address,
+ channels: row.channels || { ...DEFAULT_PREFERENCES.channels },
+ events: row.events || { ...DEFAULT_PREFERENCES.events },
+ email: row.email || null,
+ updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null,
+ };
+ }
+
+ async close() {}
+}
diff --git a/chainhook/notification-preferences.test.js b/chainhook/notification-preferences.test.js
new file mode 100644
index 00000000..60309664
--- /dev/null
+++ b/chainhook/notification-preferences.test.js
@@ -0,0 +1,215 @@
+import { describe, it, before, after } from "node:test";
+import assert from "node:assert/strict";
+import {
+ MemoryNotificationPreferencesStore,
+ validatePreferencesPayload,
+ mergePreferences,
+ DEFAULT_PREFERENCES,
+ NOTIFICATION_CHANNELS,
+ NOTIFICATION_EVENT_TYPES,
+} from "./notification-preferences.js";
+
+const VALID_ADDRESS = "SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE";
+const OTHER_ADDRESS = "SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T";
+
+describe("validatePreferencesPayload", () => {
+ it("accepts an empty object", () => {
+ const result = validatePreferencesPayload({});
+ assert.equal(result.valid, true);
+ });
+
+ it("accepts valid channel toggles", () => {
+ const result = validatePreferencesPayload({
+ channels: { in_app: false, email: true },
+ });
+ assert.equal(result.valid, true);
+ });
+
+ it("accepts valid event toggles", () => {
+ const result = validatePreferencesPayload({
+ events: { tip_received: false, tip_sent: true },
+ });
+ assert.equal(result.valid, true);
+ });
+
+ it("accepts a valid email address", () => {
+ const result = validatePreferencesPayload({ email: "user@example.com" });
+ assert.equal(result.valid, true);
+ });
+
+ it("accepts null email to clear the address", () => {
+ const result = validatePreferencesPayload({ email: null });
+ assert.equal(result.valid, true);
+ });
+
+ it("rejects a non-object body", () => {
+ const result = validatePreferencesPayload("bad");
+ assert.equal(result.valid, false);
+ assert.match(result.error, /object/);
+ });
+
+ it("rejects channels as an array", () => {
+ const result = validatePreferencesPayload({ channels: [] });
+ assert.equal(result.valid, false);
+ assert.match(result.error, /channels/);
+ });
+
+ it("rejects an unknown channel key", () => {
+ const result = validatePreferencesPayload({ channels: { sms: true } });
+ assert.equal(result.valid, false);
+ assert.match(result.error, /unknown channel/);
+ });
+
+ it("rejects a non-boolean channel value", () => {
+ const result = validatePreferencesPayload({ channels: { in_app: "yes" } });
+ assert.equal(result.valid, false);
+ assert.match(result.error, /boolean/);
+ });
+
+ it("rejects an unknown event type", () => {
+ const result = validatePreferencesPayload({ events: { unknown_event: true } });
+ assert.equal(result.valid, false);
+ assert.match(result.error, /unknown event type/);
+ });
+
+ it("rejects a non-boolean event value", () => {
+ const result = validatePreferencesPayload({ events: { tip_received: 1 } });
+ assert.equal(result.valid, false);
+ assert.match(result.error, /boolean/);
+ });
+
+ it("rejects a malformed email", () => {
+ const result = validatePreferencesPayload({ email: "not-an-email" });
+ assert.equal(result.valid, false);
+ assert.match(result.error, /email/);
+ });
+
+ it("rejects an email exceeding 254 characters", () => {
+ const long = "a".repeat(250) + "@b.co";
+ const result = validatePreferencesPayload({ email: long });
+ assert.equal(result.valid, false);
+ assert.match(result.error, /254/);
+ });
+});
+
+describe("mergePreferences", () => {
+ it("returns existing preferences unchanged when updates are empty", () => {
+ const existing = { ...DEFAULT_PREFERENCES };
+ const merged = mergePreferences(existing, {});
+ assert.deepEqual(merged.channels, existing.channels);
+ assert.deepEqual(merged.events, existing.events);
+ assert.equal(merged.email, existing.email);
+ });
+
+ it("merges channel updates without overwriting untouched channels", () => {
+ const existing = { ...DEFAULT_PREFERENCES };
+ const merged = mergePreferences(existing, { channels: { email: true } });
+ assert.equal(merged.channels.email, true);
+ assert.equal(merged.channels.in_app, DEFAULT_PREFERENCES.channels.in_app);
+ });
+
+ it("merges event updates without overwriting untouched events", () => {
+ const existing = { ...DEFAULT_PREFERENCES };
+ const merged = mergePreferences(existing, { events: { tip_sent: true } });
+ assert.equal(merged.events.tip_sent, true);
+ assert.equal(merged.events.tip_received, DEFAULT_PREFERENCES.events.tip_received);
+ });
+
+ it("updates email when provided", () => {
+ const existing = { ...DEFAULT_PREFERENCES };
+ const merged = mergePreferences(existing, { email: "test@example.com" });
+ assert.equal(merged.email, "test@example.com");
+ });
+
+ it("clears email when null is provided", () => {
+ const existing = { ...DEFAULT_PREFERENCES, email: "old@example.com" };
+ const merged = mergePreferences(existing, { email: null });
+ assert.equal(merged.email, null);
+ });
+
+ it("does not mutate the existing object", () => {
+ const existing = { ...DEFAULT_PREFERENCES };
+ mergePreferences(existing, { channels: { email: true } });
+ assert.equal(existing.channels.email, DEFAULT_PREFERENCES.channels.email);
+ });
+});
+
+describe("MemoryNotificationPreferencesStore", () => {
+ let store;
+
+ before(async () => {
+ store = new MemoryNotificationPreferencesStore();
+ await store.init();
+ });
+
+ after(async () => {
+ await store.close();
+ });
+
+ it("returns default preferences for an unknown address", async () => {
+ const prefs = await store.getPreferences(VALID_ADDRESS);
+ assert.equal(prefs.address, VALID_ADDRESS);
+ assert.deepEqual(prefs.channels, DEFAULT_PREFERENCES.channels);
+ assert.deepEqual(prefs.events, DEFAULT_PREFERENCES.events);
+ assert.equal(prefs.email, null);
+ });
+
+ it("throws for an invalid address on get", async () => {
+ await assert.rejects(() => store.getPreferences("bad-address"), /invalid address/);
+ });
+
+ it("throws for an invalid address on upsert", async () => {
+ await assert.rejects(() => store.upsertPreferences("bad", {}), /invalid address/);
+ });
+
+ it("throws for an invalid address on delete", async () => {
+ await assert.rejects(() => store.deletePreferences("bad"), /invalid address/);
+ });
+
+ it("persists preferences after upsert", async () => {
+ await store.upsertPreferences(VALID_ADDRESS, {
+ channels: { email: true },
+ email: "user@example.com",
+ });
+ const prefs = await store.getPreferences(VALID_ADDRESS);
+ assert.equal(prefs.channels.email, true);
+ assert.equal(prefs.email, "user@example.com");
+ });
+
+ it("merges partial updates without losing existing values", async () => {
+ await store.upsertPreferences(VALID_ADDRESS, { channels: { in_app: false } });
+ const prefs = await store.getPreferences(VALID_ADDRESS);
+ assert.equal(prefs.channels.in_app, false);
+ assert.equal(prefs.channels.email, true);
+ });
+
+ it("stores preferences independently per address", async () => {
+ await store.upsertPreferences(OTHER_ADDRESS, { channels: { email: true } });
+ const a = await store.getPreferences(VALID_ADDRESS);
+ const b = await store.getPreferences(OTHER_ADDRESS);
+ assert.equal(a.channels.in_app, false);
+ assert.equal(b.channels.in_app, DEFAULT_PREFERENCES.channels.in_app);
+ });
+
+ it("deletes preferences and returns deleted: true", async () => {
+ await store.upsertPreferences(VALID_ADDRESS, { email: "del@example.com" });
+ const result = await store.deletePreferences(VALID_ADDRESS);
+ assert.equal(result.deleted, true);
+ const prefs = await store.getPreferences(VALID_ADDRESS);
+ assert.equal(prefs.email, null);
+ });
+
+ it("returns deleted: false when address had no stored preferences", async () => {
+ const fresh = "SP2DEMOADDRESS0000000000000000000000ABCDEF";
+ const result = await store.deletePreferences(fresh);
+ assert.equal(result.deleted, false);
+ });
+
+ it("upsert returns the merged record with updatedAt", async () => {
+ const record = await store.upsertPreferences(OTHER_ADDRESS, {
+ events: { tip_sent: true },
+ });
+ assert.ok(record.updatedAt);
+ assert.equal(record.events.tip_sent, true);
+ });
+});
diff --git a/chainhook/server.js b/chainhook/server.js
index c316ad61..da3d8618 100644
--- a/chainhook/server.js
+++ b/chainhook/server.js
@@ -15,6 +15,14 @@ import { BadRequestError, PayloadTooLargeError, RateLimitError, UnauthorizedErro
import { ScheduledTip, validateScheduledTipParams, SCHEDULED_TIP_STATUSES } from "./scheduler.js";
import { wsManager } from "./websocket.js";
import { REFUND_STATUSES, REFUND_WINDOW_MS } from "./storage.js";
+import {
+ MemoryNotificationPreferencesStore,
+ PostgresNotificationPreferencesStore,
+ validatePreferencesPayload,
+ DEFAULT_PREFERENCES,
+ NOTIFICATION_CHANNELS,
+ NOTIFICATION_EVENT_TYPES,
+} from "./notification-preferences.js";
const PORT = process.env.PORT || 3100;
const AUTH_TOKEN = process.env.CHAINHOOK_AUTH_TOKEN || "";
@@ -40,6 +48,7 @@ const addressRateLimiter = new AddressRateLimiter(
let eventStore = null;
let scheduledTipStore = null;
let refundStore = null;
+let notificationPreferencesStore = null;
/**
* Get the rate limiter instance for runtime configuration.
@@ -98,6 +107,21 @@ async function getRefundStore() {
return refundStore;
}
+async function getNotificationPreferencesStore() {
+ if (!notificationPreferencesStore) {
+ if (STORAGE_MODE === "memory" || !DATABASE_URL) {
+ notificationPreferencesStore = new MemoryNotificationPreferencesStore();
+ await notificationPreferencesStore.init();
+ } else {
+ const { Pool } = await import("pg");
+ const pool = new Pool({ connectionString: DATABASE_URL });
+ notificationPreferencesStore = new PostgresNotificationPreferencesStore(pool);
+ await notificationPreferencesStore.init();
+ }
+ }
+ return notificationPreferencesStore;
+}
+
/**
* Read and parse a JSON request body from a readable stream.
* Rejects if the body exceeds MAX_BODY_SIZE or contains invalid JSON.
@@ -303,7 +327,7 @@ function parseTipEvent(event) {
};
}
-export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, getAddressRateLimiter, wsManager, getRefundStore };
+export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, getAddressRateLimiter, wsManager, getRefundStore, getNotificationPreferencesStore };
function checkShutdownState(res, requestId) {
if (isShuttingDown()) {
@@ -1219,6 +1243,97 @@ const server = http.createServer(async (req, res) => {
});
}
+ // GET /api/notifications/preferences/:address -- get preferences for an address
+ if (req.method === "GET" && path.match(/^\/api\/notifications\/preferences\/[^/]+$/)) {
+ try {
+ const address = path.split("/api/notifications/preferences/")[1];
+
+ if (!address || address.trim() === "") {
+ return sendError(res, new BadRequestError("address parameter is required"), requestId, { path });
+ }
+ if (!isValidStacksAddress(address)) {
+ return sendError(res, new BadRequestError("invalid address format"), requestId, { path, address });
+ }
+
+ const store = await getNotificationPreferencesStore();
+ const preferences = await store.getPreferences(address);
+ return sendJson(res, 200, { preferences });
+ } catch (err) {
+ return sendError(res, err, requestId, { path });
+ }
+ }
+
+ // PUT /api/notifications/preferences/:address -- create or update preferences
+ if (req.method === "PUT" && path.match(/^\/api\/notifications\/preferences\/[^/]+$/)) {
+ const startTime = Date.now();
+ try {
+ const address = path.split("/api/notifications/preferences/")[1];
+
+ if (!address || address.trim() === "") {
+ return sendError(res, new BadRequestError("address parameter is required"), requestId, { path });
+ }
+ if (!isValidStacksAddress(address)) {
+ return sendError(res, new BadRequestError("invalid address format"), requestId, { path, address });
+ }
+
+ const body = await parseBody(req);
+ const validation = validatePreferencesPayload(body);
+ if (!validation.valid) {
+ return sendError(res, new BadRequestError(validation.error), requestId, { path });
+ }
+
+ const store = await getNotificationPreferencesStore();
+ const preferences = await store.upsertPreferences(address, body);
+
+ const processingMs = Date.now() - startTime;
+ logger.info("Notification preferences updated", {
+ address,
+ request_id: requestId,
+ processing_ms: processingMs,
+ });
+
+ metrics.recordRequest(true);
+ return sendJson(res, 200, { ok: true, preferences });
+ } catch (err) {
+ const processingMs = Date.now() - startTime;
+ metrics.recordRequest(false);
+ return sendError(res, err, requestId, { path, processing_ms: processingMs });
+ }
+ }
+
+ // DELETE /api/notifications/preferences/:address -- reset preferences to defaults
+ if (req.method === "DELETE" && path.match(/^\/api\/notifications\/preferences\/[^/]+$/)) {
+ const startTime = Date.now();
+ try {
+ const address = path.split("/api/notifications/preferences/")[1];
+
+ if (!address || address.trim() === "") {
+ return sendError(res, new BadRequestError("address parameter is required"), requestId, { path });
+ }
+ if (!isValidStacksAddress(address)) {
+ return sendError(res, new BadRequestError("invalid address format"), requestId, { path, address });
+ }
+
+ const store = await getNotificationPreferencesStore();
+ const result = await store.deletePreferences(address);
+
+ const processingMs = Date.now() - startTime;
+ logger.info("Notification preferences reset", {
+ address,
+ deleted: result.deleted,
+ request_id: requestId,
+ processing_ms: processingMs,
+ });
+
+ metrics.recordRequest(true);
+ return sendJson(res, 200, { ok: true, deleted: result.deleted });
+ } catch (err) {
+ const processingMs = Date.now() - startTime;
+ metrics.recordRequest(false);
+ return sendError(res, err, requestId, { path, processing_ms: processingMs });
+ }
+ }
+
sendJson(res, 404, { error: "not found", path: path });
});
@@ -1247,6 +1362,9 @@ if (isMain) {
clearInterval(cleanupInterval);
wsManager.close();
await store.close();
+ if (notificationPreferencesStore) {
+ await notificationPreferencesStore.close();
+ }
logger.info("Shutdown initiated");
});
diff --git a/frontend/.env.example b/frontend/.env.example
index 4e891271..4ac0d306 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -28,3 +28,7 @@ VITE_APP_URL=https://tipstream-silk.vercel.app
# VITE_TELEMETRY_ENABLED=true
# VITE_TELEMETRY_ENDPOINT=https://telemetry.example.com/ingest
# VITE_TELEMETRY_API_KEY=your-api-key
+
+# Notification preferences are stored in localStorage per wallet address.
+# No additional configuration is required.
+# Users can manage preferences at /notification-preferences.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b163b7f2..ffad4114 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -21,10 +21,12 @@ import {
ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED,
ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_ADDRESS_BOOK,
ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, ROUTE_REFUNDS,
+ ROUTE_NOTIFICATION_PREFERENCES,
DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META,
} from './config/routes';
-import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw } from 'lucide-react';
+import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, BellCog } from 'lucide-react';
import { activateDemo, deactivateDemo } from './lib/demo-utils';
+import { useNotificationPreferences } from './context/NotificationPreferencesContext';
const AnimatedHero = lazy(() => import('./components/ui/animated-hero').then(m => ({ default: m.AnimatedHero })));
const MaintenancePage = lazy(() => import('./components/MaintenancePage'));
@@ -44,6 +46,7 @@ const NotFound = lazy(() => import('./components/NotFound'));
const AdminDashboard = lazy(() => import('./components/AdminDashboard'));
const TelemetryDashboard = lazy(() => import('./components/TelemetryDashboard'));
const RefundManager = lazy(() => import('./components/RefundManager'));
+const NotificationPreferencesPage = lazy(() => import('./components/NotificationPreferences'));
function App() {
const [userData, setUserData] = useState(null);
@@ -56,8 +59,9 @@ function App() {
const { demoEnabled, toggleDemo } = useDemoMode();
const userAddress = getMainnetAddress(userData);
- const { notifications, unreadCount, lastSeenTimestamp, markAllRead, loading: notificationsLoading } = useNotifications(userAddress);
const { isOwner } = useAdmin(userAddress);
+ const { preferences: notifPreferences } = useNotificationPreferences();
+ const { notifications, unreadCount, lastSeenTimestamp, markAllRead, loading: notificationsLoading } = useNotifications(userAddress, notifPreferences);
usePageTitle();
@@ -170,6 +174,7 @@ function App() {
{ path: ROUTE_ADDRESS_BOOK, label: 'Address Book', icon: BookUser },
{ path: ROUTE_BLOCK, label: 'Block', icon: ShieldBan },
{ path: ROUTE_REFUNDS, label: 'Refunds', icon: RotateCcw },
+ { path: ROUTE_NOTIFICATION_PREFERENCES, label: 'Notifications', icon: BellCog },
{ path: ROUTE_STATS, label: 'Stats', icon: BarChart3 },
];
@@ -402,6 +407,20 @@ function App() {
)
}
/>
+
+ {/* Notification preferences */}
+
+ ) : (
+
+
+
+ )
+ }
+ />
{/* Root and fallback */}
} />
diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx
index a1679f38..127e7c5d 100644
--- a/frontend/src/components/NotificationBell.jsx
+++ b/frontend/src/components/NotificationBell.jsx
@@ -1,11 +1,14 @@
import { useState, useRef, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
import { formatSTX, formatAddress } from '../lib/utils';
-import { Bell } from 'lucide-react';
+import { Bell, Settings } from 'lucide-react';
+import { ROUTE_NOTIFICATION_PREFERENCES } from '../config/routes';
export default function NotificationBell({ notifications, unreadCount, onMarkRead, loading, lastSeenTimestamp }) {
const [open, setOpen] = useState(false);
const dropdownRef = useRef(null);
const panelId = 'notifications-panel';
+ const navigate = useNavigate();
useEffect(() => {
const handleClickOutside = (e) => {
@@ -72,14 +75,26 @@ export default function NotificationBell({ notifications, unreadCount, onMarkRea
>
Notifications
- {notifications.length > 0 && (
+
+ {notifications.length > 0 && (
+
+ )}
- )}
+
diff --git a/frontend/src/components/NotificationPreferences.jsx b/frontend/src/components/NotificationPreferences.jsx
new file mode 100644
index 00000000..42594301
--- /dev/null
+++ b/frontend/src/components/NotificationPreferences.jsx
@@ -0,0 +1,242 @@
+import { useState } from 'react';
+import { Bell, Mail, RotateCcw } from 'lucide-react';
+import { useNotificationPreferences } from '../context/NotificationPreferencesContext';
+import {
+ CHANNELS,
+ EVENT_TYPES,
+ CHANNEL_LABELS,
+ EVENT_LABELS,
+} from '../lib/notificationPreferences';
+
+function Toggle({ id, checked, onChange, disabled }) {
+ return (
+
+ );
+}
+
+function PreferenceRow({ label, description, checked, onChange, id }) {
+ return (
+
+
+
+ {description && (
+
{description}
+ )}
+
+
+
+ );
+}
+
+const EVENT_DESCRIPTIONS = {
+ [EVENT_TYPES.TIP_RECEIVED]: 'When someone sends you a tip',
+ [EVENT_TYPES.TIP_SENT]: 'When your tip is confirmed on-chain',
+ [EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: 'When a scheduled tip runs successfully',
+ [EVENT_TYPES.SCHEDULED_TIP_FAILED]: 'When a scheduled tip cannot be executed',
+ [EVENT_TYPES.REFUND_REQUESTED]: 'When a refund is requested on a tip',
+ [EVENT_TYPES.REFUND_RESOLVED]: 'When a refund request is approved or rejected',
+};
+
+export default function NotificationPreferences({ addToast }) {
+ const {
+ preferences,
+ toggleChannel,
+ toggleEvent,
+ setEmail,
+ resetToDefaults,
+ } = useNotificationPreferences();
+
+ const [emailInput, setEmailInput] = useState(preferences.email || '');
+ const [emailError, setEmailError] = useState('');
+
+ const emailEnabled = preferences.channels[CHANNELS.EMAIL];
+
+ const handleEmailChange = (value) => {
+ setEmailInput(value);
+ if (!value) {
+ setEmailError('');
+ return;
+ }
+ const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRe.test(value)) {
+ setEmailError('Enter a valid email address');
+ } else if (value.length > 254) {
+ setEmailError('Email must be 254 characters or fewer');
+ } else {
+ setEmailError('');
+ }
+ };
+
+ const handleEmailSave = () => {
+ if (emailError) return;
+ setEmail(emailInput.trim() || null);
+ addToast?.('Email address saved', 'success');
+ };
+
+ const handleEmailKeyDown = (e) => {
+ if (e.key === 'Enter') handleEmailSave();
+ };
+
+ const handleReset = () => {
+ resetToDefaults();
+ setEmailInput('');
+ setEmailError('');
+ addToast?.('Notification preferences reset to defaults', 'info');
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Notification Preferences
+
+
+ Control which events notify you and how
+
+
+
+
+
+
+
+
+
+ Channels
+
+
+
toggleChannel(CHANNELS.IN_APP, val)}
+ />
+ toggleChannel(CHANNELS.EMAIL, val)}
+ />
+
+
+ {emailEnabled && (
+
+
+
+
+
+ handleEmailChange(e.target.value)}
+ onKeyDown={handleEmailKeyDown}
+ placeholder="you@example.com"
+ maxLength={254}
+ aria-describedby={emailError ? 'notification-email-error' : undefined}
+ aria-invalid={emailError ? 'true' : undefined}
+ className={`w-full pl-9 pr-4 py-2.5 border rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all bg-white dark:bg-gray-800 dark:text-white ${
+ emailError
+ ? 'border-red-300 dark:border-red-600'
+ : 'border-gray-200 dark:border-gray-700'
+ }`}
+ />
+
+
+
+ {emailError && (
+
+ {emailError}
+
+ )}
+ {preferences.email && !emailError && (
+
+ Saved: {preferences.email}
+
+ )}
+
+ )}
+
+
+
+
+ Event types
+
+
+ {Object.values(EVENT_TYPES).map((eventType) => (
+
toggleEvent(eventType, val)}
+ />
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/config/routes.js b/frontend/src/config/routes.js
index 2527c1c9..b30a6d6b 100644
--- a/frontend/src/config/routes.js
+++ b/frontend/src/config/routes.js
@@ -109,6 +109,12 @@ export const ROUTE_TELEMETRY = '/telemetry';
*/
export const ROUTE_REFUNDS = '/refunds';
+/**
+ * Notification preferences settings.
+ * @type {string}
+ */
+export const ROUTE_NOTIFICATION_PREFERENCES = '/notification-preferences';
+
/**
* The route that "/" redirects to when the user is authenticated.
* Change this single value to alter the default landing page site-wide.
@@ -137,6 +143,7 @@ export const ROUTE_LABELS = {
[ROUTE_ADMIN]: 'Admin',
[ROUTE_TELEMETRY]: 'Telemetry',
[ROUTE_REFUNDS]: 'Refunds',
+ [ROUTE_NOTIFICATION_PREFERENCES]: 'Notifications',
};
/**
@@ -163,6 +170,7 @@ export const ROUTE_TITLES = {
[ROUTE_ADMIN]: 'Admin Dashboard -- TipStream',
[ROUTE_TELEMETRY]: 'Telemetry -- TipStream',
[ROUTE_REFUNDS]: 'Refunds -- TipStream',
+ [ROUTE_NOTIFICATION_PREFERENCES]: 'Notification Preferences -- TipStream',
};
/**
@@ -257,4 +265,9 @@ export const ROUTE_META = {
requiresAuth: true,
adminOnly: false,
},
+ [ROUTE_NOTIFICATION_PREFERENCES]: {
+ description: 'Configure which events trigger notifications and through which channels.',
+ requiresAuth: true,
+ adminOnly: false,
+ },
};
diff --git a/frontend/src/context/NotificationPreferencesContext.jsx b/frontend/src/context/NotificationPreferencesContext.jsx
new file mode 100644
index 00000000..5ef4bf9a
--- /dev/null
+++ b/frontend/src/context/NotificationPreferencesContext.jsx
@@ -0,0 +1,105 @@
+import { createContext, useContext, useState, useCallback, useMemo } from 'react';
+import {
+ loadPreferences,
+ savePreferences,
+ clearPreferences,
+ DEFAULT_PREFERENCES,
+ isEventEnabled,
+ isChannelEnabled,
+} from '../lib/notificationPreferences';
+
+const NotificationPreferencesContext = createContext(null);
+NotificationPreferencesContext.displayName = 'NotificationPreferencesContext';
+
+export function NotificationPreferencesProvider({ children, userAddress = null }) {
+ const [preferences, setPreferences] = useState(() => {
+ return loadPreferences(userAddress);
+ });
+
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const updatePreferences = useCallback((updates) => {
+ setPreferences((prev) => {
+ const next = {
+ channels: { ...prev.channels, ...(updates.channels || {}) },
+ events: { ...prev.events, ...(updates.events || {}) },
+ email: 'email' in updates ? updates.email : prev.email,
+ };
+ savePreferences(userAddress, next);
+ return next;
+ });
+ }, [userAddress]);
+
+ const toggleChannel = useCallback((channel, enabled) => {
+ updatePreferences({ channels: { [channel]: enabled } });
+ }, [updatePreferences]);
+
+ const toggleEvent = useCallback((eventType, enabled) => {
+ updatePreferences({ events: { [eventType]: enabled } });
+ }, [updatePreferences]);
+
+ const setEmail = useCallback((email) => {
+ updatePreferences({ email: email || null });
+ }, [updatePreferences]);
+
+ const resetToDefaults = useCallback(() => {
+ clearPreferences(userAddress);
+ setPreferences({ ...DEFAULT_PREFERENCES });
+ setError(null);
+ }, [userAddress]);
+
+ const reloadFromStorage = useCallback(() => {
+ setPreferences(loadPreferences(userAddress));
+ }, [userAddress]);
+
+ const checkEventEnabled = useCallback((eventType) => {
+ return isEventEnabled(preferences, eventType);
+ }, [preferences]);
+
+ const checkChannelEnabled = useCallback((channel) => {
+ return isChannelEnabled(preferences, channel);
+ }, [preferences]);
+
+ const value = useMemo(() => ({
+ preferences,
+ saving,
+ error,
+ updatePreferences,
+ toggleChannel,
+ toggleEvent,
+ setEmail,
+ resetToDefaults,
+ reloadFromStorage,
+ isEventEnabled: checkEventEnabled,
+ isChannelEnabled: checkChannelEnabled,
+ }), [
+ preferences,
+ saving,
+ error,
+ updatePreferences,
+ toggleChannel,
+ toggleEvent,
+ setEmail,
+ resetToDefaults,
+ reloadFromStorage,
+ checkEventEnabled,
+ checkChannelEnabled,
+ ]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useNotificationPreferences() {
+ const context = useContext(NotificationPreferencesContext);
+ if (!context) {
+ throw new Error(
+ 'useNotificationPreferences must be used within a NotificationPreferencesProvider'
+ );
+ }
+ return context;
+}
diff --git a/frontend/src/hooks/useNotifications.js b/frontend/src/hooks/useNotifications.js
index 8d9ed0b3..cac41062 100644
--- a/frontend/src/hooks/useNotifications.js
+++ b/frontend/src/hooks/useNotifications.js
@@ -7,14 +7,19 @@ import {
setLastSeenTimestamp as saveLastSeenTimestamp,
migrateLegacyNotificationState
} from '../lib/notificationStorage';
+import { EVENT_TYPES, isEventEnabled, isChannelEnabled, CHANNELS } from '../lib/notificationPreferences';
/**
* useNotifications -- derives incoming-tip notifications from the shared
* event cache in TipContext instead of polling the Stacks API independently.
*
+ * Respects notification preferences: events and channels that are disabled
+ * are excluded from the returned list and unread count.
+ *
* @param {string|null} userAddress - The current user's STX address.
+ * @param {object|null} preferences - Notification preferences from NotificationPreferencesContext.
*/
-export function useNotifications(userAddress) {
+export function useNotifications(userAddress, preferences = null) {
const { events, eventsLoading } = useTipContext();
const { demoEnabled, demoNotifications, markNotificationRead } = useDemoMode();
const network = NETWORK_NAME;
@@ -38,6 +43,11 @@ export function useNotifications(userAddress) {
/** Derive received tips from the shared event cache or demo context. */
const notifications = useMemo(() => {
+ const inAppEnabled = isChannelEnabled(preferences, CHANNELS.IN_APP);
+ const tipReceivedEnabled = isEventEnabled(preferences, EVENT_TYPES.TIP_RECEIVED);
+
+ if (!inAppEnabled || !tipReceivedEnabled) return [];
+
if (demoEnabled) {
return demoNotifications.map(n => ({
id: n.id,
@@ -58,7 +68,7 @@ export function useNotifications(userAddress) {
...t,
timestamp: t.timestamp || now - idx,
}));
- }, [events, userAddress, now, demoEnabled, demoNotifications]);
+ }, [events, userAddress, now, demoEnabled, demoNotifications, preferences]);
// Derive unread count from notifications and last seen timestamp.
const unreadCount = useMemo(() => {
diff --git a/frontend/src/lib/notificationPreferences.js b/frontend/src/lib/notificationPreferences.js
new file mode 100644
index 00000000..39ce3ea4
--- /dev/null
+++ b/frontend/src/lib/notificationPreferences.js
@@ -0,0 +1,93 @@
+const STORAGE_KEY_PREFIX = 'tipstream_notif_prefs_';
+
+export const CHANNELS = {
+ IN_APP: 'in_app',
+ EMAIL: 'email',
+};
+
+export const EVENT_TYPES = {
+ TIP_RECEIVED: 'tip_received',
+ TIP_SENT: 'tip_sent',
+ SCHEDULED_TIP_EXECUTED: 'scheduled_tip_executed',
+ SCHEDULED_TIP_FAILED: 'scheduled_tip_failed',
+ REFUND_REQUESTED: 'refund_requested',
+ REFUND_RESOLVED: 'refund_resolved',
+};
+
+export const DEFAULT_PREFERENCES = {
+ channels: {
+ [CHANNELS.IN_APP]: true,
+ [CHANNELS.EMAIL]: false,
+ },
+ events: {
+ [EVENT_TYPES.TIP_RECEIVED]: true,
+ [EVENT_TYPES.TIP_SENT]: false,
+ [EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: true,
+ [EVENT_TYPES.SCHEDULED_TIP_FAILED]: true,
+ [EVENT_TYPES.REFUND_REQUESTED]: true,
+ [EVENT_TYPES.REFUND_RESOLVED]: true,
+ },
+ email: null,
+};
+
+export const EVENT_LABELS = {
+ [EVENT_TYPES.TIP_RECEIVED]: 'Tip received',
+ [EVENT_TYPES.TIP_SENT]: 'Tip sent confirmation',
+ [EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: 'Scheduled tip executed',
+ [EVENT_TYPES.SCHEDULED_TIP_FAILED]: 'Scheduled tip failed',
+ [EVENT_TYPES.REFUND_REQUESTED]: 'Refund requested',
+ [EVENT_TYPES.REFUND_RESOLVED]: 'Refund resolved',
+};
+
+export const CHANNEL_LABELS = {
+ [CHANNELS.IN_APP]: 'In-app notifications',
+ [CHANNELS.EMAIL]: 'Email notifications',
+};
+
+function storageKey(address) {
+ return `${STORAGE_KEY_PREFIX}${address}`;
+}
+
+export function loadPreferences(address) {
+ if (!address) return { ...DEFAULT_PREFERENCES };
+ try {
+ const raw = localStorage.getItem(storageKey(address));
+ if (!raw) return { ...DEFAULT_PREFERENCES };
+ const parsed = JSON.parse(raw);
+ return {
+ channels: { ...DEFAULT_PREFERENCES.channels, ...(parsed.channels || {}) },
+ events: { ...DEFAULT_PREFERENCES.events, ...(parsed.events || {}) },
+ email: parsed.email || null,
+ };
+ } catch {
+ return { ...DEFAULT_PREFERENCES };
+ }
+}
+
+export function savePreferences(address, preferences) {
+ if (!address) return;
+ try {
+ localStorage.setItem(storageKey(address), JSON.stringify(preferences));
+ } catch {
+ // localStorage may be unavailable
+ }
+}
+
+export function clearPreferences(address) {
+ if (!address) return;
+ try {
+ localStorage.removeItem(storageKey(address));
+ } catch {
+ // localStorage may be unavailable
+ }
+}
+
+export function isEventEnabled(preferences, eventType) {
+ if (!preferences) return DEFAULT_PREFERENCES.events[eventType] ?? true;
+ return preferences.events?.[eventType] ?? DEFAULT_PREFERENCES.events[eventType] ?? true;
+}
+
+export function isChannelEnabled(preferences, channel) {
+ if (!preferences) return DEFAULT_PREFERENCES.channels[channel] ?? false;
+ return preferences.channels?.[channel] ?? DEFAULT_PREFERENCES.channels[channel] ?? false;
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 1ec19f06..8d471f10 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -7,6 +7,7 @@ import ErrorBoundary from './components/ErrorBoundary.jsx'
import { TipProvider } from './context/TipContext.jsx'
import { ThemeProvider } from './context/ThemeContext.jsx'
import { DemoProvider } from './context/DemoContext.jsx'
+import { NotificationPreferencesProvider } from './context/NotificationPreferencesContext.jsx'
import { validateConfigAtStartup, reportValidationErrors } from './config/startup.js'
import { initializeTelemetrySink } from './config/telemetry.js'
@@ -32,9 +33,11 @@ createRoot(document.getElementById('root')).render(
-
-
-
+
+
+
+
+
diff --git a/frontend/src/test/NotificationBell.test.jsx b/frontend/src/test/NotificationBell.test.jsx
index 87841b12..83513a54 100644
--- a/frontend/src/test/NotificationBell.test.jsx
+++ b/frontend/src/test/NotificationBell.test.jsx
@@ -1,7 +1,16 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
import NotificationBell from '../components/NotificationBell';
+vi.mock('react-router-dom', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => vi.fn(),
+ };
+});
+
afterEach(() => {
cleanup();
vi.restoreAllMocks();
@@ -27,49 +36,57 @@ const defaultProps = {
lastSeenTimestamp: 0,
};
+function renderBell(props = {}) {
+ return render(
+
+
+
+ );
+}
+
describe('NotificationBell', () => {
describe('rendering', () => {
it('renders the bell icon button', () => {
- render(
);
+ renderBell();
expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument();
});
it('shows unread badge when unreadCount > 0', () => {
- render(
);
+ renderBell({ unreadCount: 3 });
expect(screen.getByText('3')).toBeInTheDocument();
});
it('shows 9+ when unreadCount exceeds 9', () => {
- render(
);
+ renderBell({ unreadCount: 15 });
expect(screen.getByText('9+')).toBeInTheDocument();
});
it('hides badge when unreadCount is 0', () => {
- render(
);
+ renderBell({ unreadCount: 0 });
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
it('shows exact number when unreadCount is 9', () => {
- render(
);
+ renderBell({ unreadCount: 9 });
expect(screen.getByText('9')).toBeInTheDocument();
expect(screen.queryByText('9+')).not.toBeInTheDocument();
});
it('shows 9+ when unreadCount is exactly 10', () => {
- render(
);
+ renderBell({ unreadCount: 10 });
expect(screen.getByText('9+')).toBeInTheDocument();
});
});
describe('dropdown toggle', () => {
it('opens dropdown on click', () => {
- render(
);
+ renderBell();
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByRole('region', { name: /notifications/i })).toBeInTheDocument();
});
it('closes dropdown on second click', () => {
- render(
);
+ renderBell();
const btn = screen.getByRole('button', { name: /notifications/i });
fireEvent.click(btn);
fireEvent.click(btn);
@@ -78,30 +95,39 @@ describe('NotificationBell', () => {
it('calls onMarkRead when opening with unread items', () => {
const onMarkRead = vi.fn();
- render(
);
+ renderBell({ unreadCount: 2, onMarkRead });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(onMarkRead).toHaveBeenCalledTimes(1);
});
it('does not call onMarkRead when there are no unread items', () => {
const onMarkRead = vi.fn();
- render(
);
+ renderBell({ unreadCount: 0, onMarkRead });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(onMarkRead).not.toHaveBeenCalled();
});
});
+ describe('settings shortcut', () => {
+ it('renders the notification settings button inside the dropdown', () => {
+ renderBell();
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ expect(screen.getByRole('button', { name: /notification settings/i })).toBeInTheDocument();
+ });
+
+ it('closes the dropdown when settings button is clicked', () => {
+ renderBell();
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ fireEvent.click(screen.getByRole('button', { name: /notification settings/i }));
+ expect(screen.queryByRole('region', { name: /notifications/i })).not.toBeInTheDocument();
+ });
+ });
+
describe('read/unread visual distinction', () => {
it('shows green dot for unread notifications', () => {
const now = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: now + 10 })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: now });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dot = container.querySelector('.bg-green-400');
expect(dot).toBeInTheDocument();
@@ -110,13 +136,7 @@ describe('NotificationBell', () => {
it('hides green dot for read notifications', () => {
const now = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: now - 100 })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: now });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dots = container.querySelectorAll('.bg-green-400');
expect(dots.length).toBe(0);
@@ -125,13 +145,7 @@ describe('NotificationBell', () => {
it('shows background highlight for unread items', () => {
const now = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: now + 10 })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: now });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const highlighted = container.querySelector('[class*="bg-blue-50"]');
expect(highlighted).toBeInTheDocument();
@@ -140,13 +154,7 @@ describe('NotificationBell', () => {
it('does not show background highlight for read items', () => {
const now = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: now - 100 })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: now });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const highlighted = container.querySelector('[class*="bg-blue-50"]');
expect(highlighted).not.toBeInTheDocument();
@@ -158,13 +166,7 @@ describe('NotificationBell', () => {
makeNotification({ txId: '0xnew1', timestamp: now + 10 }),
makeNotification({ txId: '0xold1', timestamp: now - 100 }),
];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: now });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dots = container.querySelectorAll('.bg-green-400');
expect(dots.length).toBe(1);
@@ -173,13 +175,7 @@ describe('NotificationBell', () => {
it('treats all items as read when lastSeenTimestamp is null', () => {
const now = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: now + 10 })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: null });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dots = container.querySelectorAll('.bg-green-400');
expect(dots.length).toBe(0);
@@ -191,13 +187,7 @@ describe('NotificationBell', () => {
makeNotification({ txId: '0xa', timestamp: now }),
makeNotification({ txId: '0xb', timestamp: now - 60 }),
];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: 0 });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dots = container.querySelectorAll('.bg-green-400');
expect(dots.length).toBe(2);
@@ -208,13 +198,7 @@ describe('NotificationBell', () => {
it('treats notification at exact lastSeenTimestamp as read', () => {
const ts = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: ts })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: ts });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dots = container.querySelectorAll('.bg-green-400');
expect(dots.length).toBe(0);
@@ -223,13 +207,7 @@ describe('NotificationBell', () => {
it('treats notification one second after lastSeen as unread', () => {
const ts = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: ts + 1 })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: ts });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dots = container.querySelectorAll('.bg-green-400');
expect(dots.length).toBe(1);
@@ -238,13 +216,13 @@ describe('NotificationBell', () => {
describe('empty states', () => {
it('shows loading text when loading with no notifications', () => {
- render(
);
+ renderBell({ loading: true });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows empty message when not loading and no notifications', () => {
- render(
);
+ renderBell();
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByText('No tips received yet')).toBeInTheDocument();
});
@@ -252,27 +230,27 @@ describe('NotificationBell', () => {
describe('accessibility', () => {
it('bell button has accessible label', () => {
- render(
);
- const btn = screen.getByRole('button');
- expect(btn).toHaveAttribute('aria-label', 'Notifications (5 unread)');
+ renderBell({ unreadCount: 5 });
+ const btn = screen.getByRole('button', { name: /notifications \(5 unread\)/i });
+ expect(btn).toBeInTheDocument();
});
it('bell button has default label with no unread', () => {
- render(
);
- const btn = screen.getByRole('button');
+ renderBell();
+ const btn = screen.getByRole('button', { name: /^notifications$/i });
expect(btn).toHaveAttribute('aria-label', 'Notifications');
});
it('dropdown has region role', () => {
- render(
);
- fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ renderBell();
+ fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
expect(screen.getByRole('region')).toBeInTheDocument();
});
});
describe('click outside', () => {
it('closes dropdown when clicking outside', () => {
- render(
);
+ renderBell();
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByRole('region')).toBeInTheDocument();
fireEvent.mouseDown(document.body);
@@ -283,7 +261,7 @@ describe('NotificationBell', () => {
describe('notification items', () => {
it('renders notification items', () => {
const notifications = [makeNotification({ amount: '2000000' })];
- render(
);
+ renderBell({ notifications });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByText(/STX/)).toBeInTheDocument();
});
@@ -292,9 +270,7 @@ describe('NotificationBell', () => {
const notifications = Array.from({ length: 25 }, (_, i) =>
makeNotification({ txId: `0x${String(i).padStart(8, '0')}` })
);
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const items = container.querySelectorAll('.flex.items-start');
expect(items.length).toBe(20);
@@ -303,13 +279,7 @@ describe('NotificationBell', () => {
it('unread dot has correct size and shape classes', () => {
const now = Math.floor(Date.now() / 1000);
const notifications = [makeNotification({ timestamp: now + 10 })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: now });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const dot = container.querySelector('.bg-green-400');
expect(dot.className).toContain('w-2');
@@ -325,13 +295,7 @@ describe('NotificationBell', () => {
makeNotification({ txId: '0xbbb', timestamp: now + 10 }),
makeNotification({ txId: '0xccc', timestamp: now + 15 }),
];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications, lastSeenTimestamp: now });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const highlighted = container.querySelectorAll('[class*="bg-blue-50"]');
expect(highlighted.length).toBe(3);
@@ -341,9 +305,7 @@ describe('NotificationBell', () => {
describe('stable keys for notification rows', () => {
it('uses txId as key when available', () => {
const notifications = [makeNotification({ txId: '0xabc123' })];
- const { container } = render(
-
- );
+ const { container } = renderBell({ notifications });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const items = container.querySelectorAll('[class*="px-4"]');
expect(items.length).toBeGreaterThan(0);
@@ -358,12 +320,7 @@ describe('NotificationBell', () => {
amount: '1000000',
timestamp: Math.floor(Date.now() / 1000),
};
- render(
-
- );
+ renderBell({ notifications: [notificationWithoutTxId] });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByText(/STX/)).toBeInTheDocument();
});
@@ -376,10 +333,7 @@ describe('NotificationBell', () => {
amount: '1000000',
timestamp: 1234567890,
};
- const notifications = [notificationWithoutIdFields];
- render(
-
- );
+ renderBell({ notifications: [notificationWithoutIdFields] });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByText(/STX/)).toBeInTheDocument();
});
@@ -390,20 +344,15 @@ describe('NotificationBell', () => {
makeNotification({ txId: '0xbbb', amount: '2000000' }),
makeNotification({ txId: '0xccc', amount: '3000000' }),
];
- const { rerender } = render(
-
- );
+ const { rerender } = renderBell({ notifications });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
-
- const reorderedNotifications = [
- notifications[2],
- notifications[0],
- notifications[1],
- ];
+
+ const reorderedNotifications = [notifications[2], notifications[0], notifications[1]];
rerender(
-
+
+
+
);
-
expect(screen.getAllByText(/STX/)).toHaveLength(3);
});
@@ -419,9 +368,7 @@ describe('NotificationBell', () => {
},
makeNotification({ txId: '0xdef456' }),
];
- render(
-
- );
+ renderBell({ notifications });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getAllByText(/STX/)).toHaveLength(3);
});
@@ -430,16 +377,15 @@ describe('NotificationBell', () => {
const initialNotifications = Array.from({ length: 25 }, (_, i) =>
makeNotification({ txId: `0x${String(i).padStart(8, '0')}` })
);
- const { rerender } = render(
-
- );
+ const { rerender } = renderBell({ notifications: initialNotifications });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
-
+
const trimmedNotifications = initialNotifications.slice(0, 10);
rerender(
-
+
+
+
);
-
expect(screen.getAllByText(/STX/)).toHaveLength(10);
});
});
@@ -447,7 +393,7 @@ describe('NotificationBell', () => {
describe('mark all read button', () => {
it('shows mark all read button when notifications exist', () => {
const notifications = [makeNotification()];
- render(
);
+ renderBell({ notifications });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
expect(screen.getByText('Mark all read')).toBeInTheDocument();
});
@@ -455,13 +401,7 @@ describe('NotificationBell', () => {
it('calls onMarkRead when mark all read is clicked', () => {
const onMarkRead = vi.fn();
const notifications = [makeNotification()];
- render(
-
- );
+ renderBell({ notifications, onMarkRead });
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
fireEvent.click(screen.getByText('Mark all read'));
expect(onMarkRead).toHaveBeenCalled();
diff --git a/frontend/src/test/NotificationPreferences.test.jsx b/frontend/src/test/NotificationPreferences.test.jsx
new file mode 100644
index 00000000..f2ffc94a
--- /dev/null
+++ b/frontend/src/test/NotificationPreferences.test.jsx
@@ -0,0 +1,264 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import NotificationPreferences from '../components/NotificationPreferences';
+import { NotificationPreferencesProvider } from '../context/NotificationPreferencesContext';
+import { CHANNELS, EVENT_TYPES, EVENT_LABELS, CHANNEL_LABELS } from '../lib/notificationPreferences';
+
+const ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE';
+
+function renderWithProvider(addToast = vi.fn()) {
+ return render(
+
+
+
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+describe('NotificationPreferences rendering', () => {
+ it('renders the component with a heading', () => {
+ renderWithProvider();
+ expect(screen.getByText('Notification Preferences')).toBeInTheDocument();
+ });
+
+ it('renders the Channels section heading', () => {
+ renderWithProvider();
+ expect(screen.getByText('Channels')).toBeInTheDocument();
+ });
+
+ it('renders the Event types section heading', () => {
+ renderWithProvider();
+ expect(screen.getByText('Event types')).toBeInTheDocument();
+ });
+
+ it('renders the in-app channel toggle', () => {
+ renderWithProvider();
+ expect(screen.getByText(CHANNEL_LABELS[CHANNELS.IN_APP])).toBeInTheDocument();
+ });
+
+ it('renders the email channel toggle', () => {
+ renderWithProvider();
+ expect(screen.getByText(CHANNEL_LABELS[CHANNELS.EMAIL])).toBeInTheDocument();
+ });
+
+ it('renders all event type labels', () => {
+ renderWithProvider();
+ Object.values(EVENT_LABELS).forEach((label) => {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ });
+ });
+
+ it('renders the reset button', () => {
+ renderWithProvider();
+ expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument();
+ });
+
+ it('has the correct data-testid', () => {
+ renderWithProvider();
+ expect(screen.getByTestId('notification-preferences')).toBeInTheDocument();
+ });
+});
+
+describe('channel toggles', () => {
+ it('in-app toggle is checked by default', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: /in-app/i });
+ expect(toggle).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('email toggle is unchecked by default', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: /email notifications/i });
+ expect(toggle).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('clicking in-app toggle disables it', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: /in-app/i });
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('clicking email toggle enables it', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: /email notifications/i });
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('persists channel toggle to localStorage', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: /email notifications/i });
+ fireEvent.click(toggle);
+ const stored = JSON.parse(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`));
+ expect(stored.channels[CHANNELS.EMAIL]).toBe(true);
+ });
+});
+
+describe('email input', () => {
+ it('does not show email input when email channel is disabled', () => {
+ renderWithProvider();
+ expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument();
+ });
+
+ it('shows email input when email channel is enabled', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: /email notifications/i });
+ fireEvent.click(toggle);
+ expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
+ });
+
+ it('shows validation error for an invalid email', () => {
+ renderWithProvider();
+ fireEvent.click(screen.getByRole('switch', { name: /email notifications/i }));
+ const input = screen.getByLabelText(/email address/i);
+ fireEvent.change(input, { target: { value: 'not-valid' } });
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByRole('alert').textContent).toMatch(/valid email/i);
+ });
+
+ it('clears validation error when a valid email is entered', () => {
+ renderWithProvider();
+ fireEvent.click(screen.getByRole('switch', { name: /email notifications/i }));
+ const input = screen.getByLabelText(/email address/i);
+ fireEvent.change(input, { target: { value: 'not-valid' } });
+ fireEvent.change(input, { target: { value: 'valid@example.com' } });
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('saves email on Save button click and calls addToast', () => {
+ const addToast = vi.fn();
+ renderWithProvider(addToast);
+ fireEvent.click(screen.getByRole('switch', { name: /email notifications/i }));
+ const input = screen.getByLabelText(/email address/i);
+ fireEvent.change(input, { target: { value: 'save@example.com' } });
+ fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
+ expect(addToast).toHaveBeenCalledWith('Email address saved', 'success');
+ });
+
+ it('saves email on Enter key press', () => {
+ const addToast = vi.fn();
+ renderWithProvider(addToast);
+ fireEvent.click(screen.getByRole('switch', { name: /email notifications/i }));
+ const input = screen.getByLabelText(/email address/i);
+ fireEvent.change(input, { target: { value: 'enter@example.com' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(addToast).toHaveBeenCalledWith('Email address saved', 'success');
+ });
+
+ it('Save button is disabled when email is invalid', () => {
+ renderWithProvider();
+ fireEvent.click(screen.getByRole('switch', { name: /email notifications/i }));
+ const input = screen.getByLabelText(/email address/i);
+ fireEvent.change(input, { target: { value: 'bad' } });
+ const saveBtn = screen.getByRole('button', { name: /^save$/i });
+ expect(saveBtn).toBeDisabled();
+ });
+});
+
+describe('event type toggles', () => {
+ it('tip_received toggle is checked by default', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_RECEIVED], 'i') });
+ expect(toggle).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('tip_sent toggle is unchecked by default', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_SENT], 'i') });
+ expect(toggle).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('clicking tip_sent toggle enables it', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_SENT], 'i') });
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('clicking tip_received toggle disables it', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_RECEIVED], 'i') });
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('persists event toggle to localStorage', () => {
+ renderWithProvider();
+ const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_SENT], 'i') });
+ fireEvent.click(toggle);
+ const stored = JSON.parse(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`));
+ expect(stored.events[EVENT_TYPES.TIP_SENT]).toBe(true);
+ });
+});
+
+describe('reset button', () => {
+ it('resets all toggles to defaults', () => {
+ renderWithProvider();
+ const inAppToggle = screen.getByRole('switch', { name: /in-app/i });
+ fireEvent.click(inAppToggle);
+ expect(inAppToggle).toHaveAttribute('aria-checked', 'false');
+
+ fireEvent.click(screen.getByRole('button', { name: /reset/i }));
+ expect(inAppToggle).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('calls addToast with info type on reset', () => {
+ const addToast = vi.fn();
+ renderWithProvider(addToast);
+ fireEvent.click(screen.getByRole('button', { name: /reset/i }));
+ expect(addToast).toHaveBeenCalledWith(
+ 'Notification preferences reset to defaults',
+ 'info'
+ );
+ });
+
+ it('clears localStorage on reset', () => {
+ renderWithProvider();
+ fireEvent.click(screen.getByRole('switch', { name: /email notifications/i }));
+ fireEvent.click(screen.getByRole('button', { name: /reset/i }));
+ expect(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)).toBeNull();
+ });
+
+ it('hides email input after reset when email channel was enabled', () => {
+ renderWithProvider();
+ fireEvent.click(screen.getByRole('switch', { name: /email notifications/i }));
+ expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /reset/i }));
+ expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument();
+ });
+});
+
+describe('accessibility', () => {
+ it('channels section has an accessible heading', () => {
+ renderWithProvider();
+ expect(screen.getByRole('region', { name: /channels/i })).toBeInTheDocument();
+ });
+
+ it('event types section has an accessible heading', () => {
+ renderWithProvider();
+ expect(screen.getByRole('region', { name: /event types/i })).toBeInTheDocument();
+ });
+
+ it('all toggles have role switch', () => {
+ renderWithProvider();
+ const switches = screen.getAllByRole('switch');
+ expect(switches.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('reset button has an accessible aria-label', () => {
+ renderWithProvider();
+ expect(
+ screen.getByRole('button', { name: /reset notification preferences/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/NotificationPreferencesContext.test.jsx b/frontend/src/test/NotificationPreferencesContext.test.jsx
new file mode 100644
index 00000000..37bcc310
--- /dev/null
+++ b/frontend/src/test/NotificationPreferencesContext.test.jsx
@@ -0,0 +1,284 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import {
+ NotificationPreferencesProvider,
+ useNotificationPreferences,
+} from '../context/NotificationPreferencesContext';
+import {
+ DEFAULT_PREFERENCES,
+ CHANNELS,
+ EVENT_TYPES,
+} from '../lib/notificationPreferences';
+
+const ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE';
+
+function wrapper({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function wrapperNoAddress({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+afterEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+describe('useNotificationPreferences', () => {
+ it('throws when used outside the provider', () => {
+ expect(() => {
+ renderHook(() => useNotificationPreferences());
+ }).toThrow('useNotificationPreferences must be used within');
+ });
+
+ it('returns default preferences on first render', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ expect(result.current.preferences.channels).toEqual(DEFAULT_PREFERENCES.channels);
+ expect(result.current.preferences.events).toEqual(DEFAULT_PREFERENCES.events);
+ expect(result.current.preferences.email).toBeNull();
+ });
+
+ it('works without a userAddress prop', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), {
+ wrapper: wrapperNoAddress,
+ });
+ expect(result.current.preferences.channels).toEqual(DEFAULT_PREFERENCES.channels);
+ });
+});
+
+describe('toggleChannel', () => {
+ it('disables in_app channel', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleChannel(CHANNELS.IN_APP, false);
+ });
+ expect(result.current.preferences.channels[CHANNELS.IN_APP]).toBe(false);
+ });
+
+ it('enables email channel', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleChannel(CHANNELS.EMAIL, true);
+ });
+ expect(result.current.preferences.channels[CHANNELS.EMAIL]).toBe(true);
+ });
+
+ it('does not affect other channels when toggling one', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleChannel(CHANNELS.EMAIL, true);
+ });
+ expect(result.current.preferences.channels[CHANNELS.IN_APP]).toBe(
+ DEFAULT_PREFERENCES.channels[CHANNELS.IN_APP]
+ );
+ });
+
+ it('persists the change to localStorage', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleChannel(CHANNELS.EMAIL, true);
+ });
+ const stored = JSON.parse(
+ localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)
+ );
+ expect(stored.channels[CHANNELS.EMAIL]).toBe(true);
+ });
+});
+
+describe('toggleEvent', () => {
+ it('disables tip_received event', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleEvent(EVENT_TYPES.TIP_RECEIVED, false);
+ });
+ expect(result.current.preferences.events[EVENT_TYPES.TIP_RECEIVED]).toBe(false);
+ });
+
+ it('enables tip_sent event', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true);
+ });
+ expect(result.current.preferences.events[EVENT_TYPES.TIP_SENT]).toBe(true);
+ });
+
+ it('does not affect other events when toggling one', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true);
+ });
+ expect(result.current.preferences.events[EVENT_TYPES.TIP_RECEIVED]).toBe(
+ DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_RECEIVED]
+ );
+ });
+
+ it('persists the change to localStorage', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true);
+ });
+ const stored = JSON.parse(
+ localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)
+ );
+ expect(stored.events[EVENT_TYPES.TIP_SENT]).toBe(true);
+ });
+});
+
+describe('setEmail', () => {
+ it('stores an email address', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.setEmail('user@example.com');
+ });
+ expect(result.current.preferences.email).toBe('user@example.com');
+ });
+
+ it('clears the email when called with empty string', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.setEmail('user@example.com');
+ });
+ act(() => {
+ result.current.setEmail('');
+ });
+ expect(result.current.preferences.email).toBeNull();
+ });
+
+ it('clears the email when called with null', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.setEmail('user@example.com');
+ });
+ act(() => {
+ result.current.setEmail(null);
+ });
+ expect(result.current.preferences.email).toBeNull();
+ });
+});
+
+describe('resetToDefaults', () => {
+ it('restores all preferences to defaults', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleChannel(CHANNELS.IN_APP, false);
+ result.current.toggleEvent(EVENT_TYPES.TIP_RECEIVED, false);
+ result.current.setEmail('user@example.com');
+ });
+ act(() => {
+ result.current.resetToDefaults();
+ });
+ expect(result.current.preferences.channels).toEqual(DEFAULT_PREFERENCES.channels);
+ expect(result.current.preferences.events).toEqual(DEFAULT_PREFERENCES.events);
+ expect(result.current.preferences.email).toBeNull();
+ });
+
+ it('removes the localStorage entry', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleChannel(CHANNELS.EMAIL, true);
+ });
+ act(() => {
+ result.current.resetToDefaults();
+ });
+ expect(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)).toBeNull();
+ });
+});
+
+describe('isEventEnabled helper', () => {
+ it('returns true for tip_received with default preferences', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ expect(result.current.isEventEnabled(EVENT_TYPES.TIP_RECEIVED)).toBe(true);
+ });
+
+ it('returns false for tip_sent with default preferences', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ expect(result.current.isEventEnabled(EVENT_TYPES.TIP_SENT)).toBe(false);
+ });
+
+ it('reflects a toggled event', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true);
+ });
+ expect(result.current.isEventEnabled(EVENT_TYPES.TIP_SENT)).toBe(true);
+ });
+});
+
+describe('isChannelEnabled helper', () => {
+ it('returns true for in_app with default preferences', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ expect(result.current.isChannelEnabled(CHANNELS.IN_APP)).toBe(true);
+ });
+
+ it('returns false for email with default preferences', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ expect(result.current.isChannelEnabled(CHANNELS.EMAIL)).toBe(false);
+ });
+
+ it('reflects a toggled channel', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.toggleChannel(CHANNELS.EMAIL, true);
+ });
+ expect(result.current.isChannelEnabled(CHANNELS.EMAIL)).toBe(true);
+ });
+});
+
+describe('updatePreferences', () => {
+ it('applies a full preferences update', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.updatePreferences({
+ channels: { email: true },
+ events: { tip_sent: true },
+ email: 'bulk@example.com',
+ });
+ });
+ expect(result.current.preferences.channels.email).toBe(true);
+ expect(result.current.preferences.events.tip_sent).toBe(true);
+ expect(result.current.preferences.email).toBe('bulk@example.com');
+ });
+
+ it('does not overwrite untouched fields in a partial update', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ act(() => {
+ result.current.updatePreferences({ channels: { email: true } });
+ });
+ expect(result.current.preferences.channels.in_app).toBe(
+ DEFAULT_PREFERENCES.channels.in_app
+ );
+ expect(result.current.preferences.events).toEqual(DEFAULT_PREFERENCES.events);
+ });
+});
+
+describe('reloadFromStorage', () => {
+ it('picks up changes written directly to localStorage', () => {
+ const { result } = renderHook(() => useNotificationPreferences(), { wrapper });
+ localStorage.setItem(
+ `tipstream_notif_prefs_${ADDRESS}`,
+ JSON.stringify({
+ channels: { in_app: false, email: true },
+ events: DEFAULT_PREFERENCES.events,
+ email: 'reload@example.com',
+ })
+ );
+ act(() => {
+ result.current.reloadFromStorage();
+ });
+ expect(result.current.preferences.channels.in_app).toBe(false);
+ expect(result.current.preferences.email).toBe('reload@example.com');
+ });
+});
diff --git a/frontend/src/test/notificationPreferences.test.js b/frontend/src/test/notificationPreferences.test.js
new file mode 100644
index 00000000..6ef97f07
--- /dev/null
+++ b/frontend/src/test/notificationPreferences.test.js
@@ -0,0 +1,173 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ loadPreferences,
+ savePreferences,
+ clearPreferences,
+ isEventEnabled,
+ isChannelEnabled,
+ DEFAULT_PREFERENCES,
+ CHANNELS,
+ EVENT_TYPES,
+} from '../lib/notificationPreferences';
+
+const ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE';
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe('loadPreferences', () => {
+ it('returns defaults when nothing is stored', () => {
+ const prefs = loadPreferences(ADDRESS);
+ expect(prefs.channels).toEqual(DEFAULT_PREFERENCES.channels);
+ expect(prefs.events).toEqual(DEFAULT_PREFERENCES.events);
+ expect(prefs.email).toBeNull();
+ });
+
+ it('returns defaults when address is null', () => {
+ const prefs = loadPreferences(null);
+ expect(prefs.channels).toEqual(DEFAULT_PREFERENCES.channels);
+ });
+
+ it('returns stored preferences after savePreferences', () => {
+ const custom = {
+ channels: { in_app: false, email: true },
+ events: { ...DEFAULT_PREFERENCES.events, tip_sent: true },
+ email: 'user@example.com',
+ };
+ savePreferences(ADDRESS, custom);
+ const loaded = loadPreferences(ADDRESS);
+ expect(loaded.channels.in_app).toBe(false);
+ expect(loaded.channels.email).toBe(true);
+ expect(loaded.events.tip_sent).toBe(true);
+ expect(loaded.email).toBe('user@example.com');
+ });
+
+ it('merges stored channels with defaults for missing keys', () => {
+ localStorage.setItem(
+ `tipstream_notif_prefs_${ADDRESS}`,
+ JSON.stringify({ channels: { email: true } })
+ );
+ const prefs = loadPreferences(ADDRESS);
+ expect(prefs.channels.email).toBe(true);
+ expect(prefs.channels.in_app).toBe(DEFAULT_PREFERENCES.channels.in_app);
+ });
+
+ it('returns defaults when stored JSON is malformed', () => {
+ localStorage.setItem(`tipstream_notif_prefs_${ADDRESS}`, 'not-json');
+ const prefs = loadPreferences(ADDRESS);
+ expect(prefs.channels).toEqual(DEFAULT_PREFERENCES.channels);
+ });
+});
+
+describe('savePreferences', () => {
+ it('persists preferences to localStorage', () => {
+ savePreferences(ADDRESS, { ...DEFAULT_PREFERENCES, email: 'a@b.com' });
+ const raw = localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`);
+ expect(raw).toBeTruthy();
+ const parsed = JSON.parse(raw);
+ expect(parsed.email).toBe('a@b.com');
+ });
+
+ it('does nothing when address is null', () => {
+ savePreferences(null, DEFAULT_PREFERENCES);
+ expect(localStorage.length).toBe(0);
+ });
+});
+
+describe('clearPreferences', () => {
+ it('removes the stored key', () => {
+ savePreferences(ADDRESS, DEFAULT_PREFERENCES);
+ clearPreferences(ADDRESS);
+ expect(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)).toBeNull();
+ });
+
+ it('does nothing when address is null', () => {
+ savePreferences(ADDRESS, DEFAULT_PREFERENCES);
+ clearPreferences(null);
+ expect(localStorage.length).toBe(1);
+ });
+});
+
+describe('isEventEnabled', () => {
+ it('returns the default value when preferences is null', () => {
+ expect(isEventEnabled(null, EVENT_TYPES.TIP_RECEIVED)).toBe(
+ DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_RECEIVED]
+ );
+ });
+
+ it('returns true for tip_received by default', () => {
+ expect(isEventEnabled(DEFAULT_PREFERENCES, EVENT_TYPES.TIP_RECEIVED)).toBe(true);
+ });
+
+ it('returns false for tip_sent by default', () => {
+ expect(isEventEnabled(DEFAULT_PREFERENCES, EVENT_TYPES.TIP_SENT)).toBe(false);
+ });
+
+ it('reflects a toggled value', () => {
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ events: { ...DEFAULT_PREFERENCES.events, tip_sent: true },
+ };
+ expect(isEventEnabled(prefs, EVENT_TYPES.TIP_SENT)).toBe(true);
+ });
+
+ it('falls back to default when event key is missing from preferences', () => {
+ const prefs = { channels: DEFAULT_PREFERENCES.channels, events: {}, email: null };
+ expect(isEventEnabled(prefs, EVENT_TYPES.TIP_RECEIVED)).toBe(true);
+ });
+});
+
+describe('isChannelEnabled', () => {
+ it('returns the default value when preferences is null', () => {
+ expect(isChannelEnabled(null, CHANNELS.IN_APP)).toBe(
+ DEFAULT_PREFERENCES.channels[CHANNELS.IN_APP]
+ );
+ });
+
+ it('returns true for in_app by default', () => {
+ expect(isChannelEnabled(DEFAULT_PREFERENCES, CHANNELS.IN_APP)).toBe(true);
+ });
+
+ it('returns false for email by default', () => {
+ expect(isChannelEnabled(DEFAULT_PREFERENCES, CHANNELS.EMAIL)).toBe(false);
+ });
+
+ it('reflects a toggled value', () => {
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ channels: { ...DEFAULT_PREFERENCES.channels, email: true },
+ };
+ expect(isChannelEnabled(prefs, CHANNELS.EMAIL)).toBe(true);
+ });
+});
+
+describe('DEFAULT_PREFERENCES shape', () => {
+ it('has in_app enabled and email disabled by default', () => {
+ expect(DEFAULT_PREFERENCES.channels[CHANNELS.IN_APP]).toBe(true);
+ expect(DEFAULT_PREFERENCES.channels[CHANNELS.EMAIL]).toBe(false);
+ });
+
+ it('has tip_received enabled by default', () => {
+ expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_RECEIVED]).toBe(true);
+ });
+
+ it('has tip_sent disabled by default', () => {
+ expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_SENT]).toBe(false);
+ });
+
+ it('has all scheduled and refund events enabled by default', () => {
+ expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.SCHEDULED_TIP_EXECUTED]).toBe(true);
+ expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.SCHEDULED_TIP_FAILED]).toBe(true);
+ expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.REFUND_REQUESTED]).toBe(true);
+ expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.REFUND_RESOLVED]).toBe(true);
+ });
+
+ it('has null email by default', () => {
+ expect(DEFAULT_PREFERENCES.email).toBeNull();
+ });
+});
diff --git a/frontend/src/test/useNotificationsPreferences.test.js b/frontend/src/test/useNotificationsPreferences.test.js
new file mode 100644
index 00000000..9247e6b3
--- /dev/null
+++ b/frontend/src/test/useNotificationsPreferences.test.js
@@ -0,0 +1,179 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { DEFAULT_PREFERENCES, CHANNELS, EVENT_TYPES } from '../lib/notificationPreferences';
+
+vi.mock('../context/TipContext', () => ({
+ useTipContext: vi.fn(() => ({
+ events: [],
+ eventsLoading: false,
+ })),
+}));
+
+vi.mock('../config/contracts', () => ({
+ NETWORK_NAME: 'mainnet',
+}));
+
+import { useNotifications } from '../hooks/useNotifications';
+import { useTipContext } from '../context/TipContext';
+
+const USER_ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE';
+
+function makeTipEvent(overrides = {}) {
+ return {
+ event: 'tip-sent',
+ sender: 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T',
+ recipient: USER_ADDRESS,
+ amount: '1000000',
+ timestamp: Math.floor(Date.now() / 1000) + 100,
+ txId: '0x' + Math.random().toString(16).slice(2, 18),
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+afterEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+describe('useNotifications with preferences', () => {
+ it('returns notifications when in_app channel is enabled (default)', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const { result } = renderHook(() =>
+ useNotifications(USER_ADDRESS, DEFAULT_PREFERENCES)
+ );
+ expect(result.current.notifications.length).toBe(1);
+ });
+
+ it('returns empty notifications when in_app channel is disabled', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false },
+ };
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs));
+ expect(result.current.notifications).toEqual([]);
+ expect(result.current.unreadCount).toBe(0);
+ });
+
+ it('returns empty notifications when tip_received event is disabled', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ events: { ...DEFAULT_PREFERENCES.events, [EVENT_TYPES.TIP_RECEIVED]: false },
+ };
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs));
+ expect(result.current.notifications).toEqual([]);
+ expect(result.current.unreadCount).toBe(0);
+ });
+
+ it('returns empty notifications when both in_app and tip_received are disabled', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent(), makeTipEvent()],
+ eventsLoading: false,
+ });
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false },
+ events: { ...DEFAULT_PREFERENCES.events, [EVENT_TYPES.TIP_RECEIVED]: false },
+ };
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs));
+ expect(result.current.notifications).toEqual([]);
+ });
+
+ it('returns notifications when preferences is null (backward compat)', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS, null));
+ expect(result.current.notifications.length).toBe(1);
+ });
+
+ it('returns notifications when preferences is undefined (backward compat)', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS));
+ expect(result.current.notifications.length).toBe(1);
+ });
+
+ it('unread count is 0 when in_app is disabled even with new events', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false },
+ };
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs));
+ expect(result.current.unreadCount).toBe(0);
+ });
+
+ it('unread count is 0 when tip_received is disabled even with new events', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ events: { ...DEFAULT_PREFERENCES.events, [EVENT_TYPES.TIP_RECEIVED]: false },
+ };
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs));
+ expect(result.current.unreadCount).toBe(0);
+ });
+
+ it('re-enables notifications when preferences change back to enabled', () => {
+ const event = makeTipEvent();
+ useTipContext.mockReturnValue({
+ events: [event],
+ eventsLoading: false,
+ });
+
+ const disabledPrefs = {
+ ...DEFAULT_PREFERENCES,
+ channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false },
+ };
+
+ const { result, rerender } = renderHook(
+ ({ prefs }) => useNotifications(USER_ADDRESS, prefs),
+ { initialProps: { prefs: disabledPrefs } }
+ );
+ expect(result.current.notifications).toEqual([]);
+
+ rerender({ prefs: DEFAULT_PREFERENCES });
+ expect(result.current.notifications.length).toBe(1);
+ });
+
+ it('other event types in preferences do not affect tip_received notifications', () => {
+ useTipContext.mockReturnValue({
+ events: [makeTipEvent()],
+ eventsLoading: false,
+ });
+ const prefs = {
+ ...DEFAULT_PREFERENCES,
+ events: {
+ ...DEFAULT_PREFERENCES.events,
+ [EVENT_TYPES.TIP_SENT]: false,
+ [EVENT_TYPES.REFUND_REQUESTED]: false,
+ },
+ };
+ const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs));
+ expect(result.current.notifications.length).toBe(1);
+ });
+});