From 79275088f72456a516f067b53a6d0de91144f5a0 Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Wed, 17 Jun 2026 17:25:39 +0000 Subject: [PATCH] feat: implement user notification preferences (#26) - Add PreferenceStore with get/update/isCategoryEnabled methods - Add GET /api/preferences/:userId and PUT /api/preferences/:userId endpoints - Gate Discord notifications in EventSubscriber on user preferences - Add optional userId field to ContractConfig for per-contract preference binding - Add 23 tests covering store, API endpoints, and notification gate - Add listener/API.md documenting all endpoints --- listener/API.md | 140 ++++++++++++++++++ listener/src/api/events-server.test.ts | 120 +++++++++++++++ listener/src/api/events-server.ts | 52 +++++-- .../src/services/event-subscriber.test.ts | 76 ++++++++++ listener/src/services/event-subscriber.ts | 10 ++ listener/src/store/preference-store.test.ts | 77 ++++++++++ listener/src/store/preference-store.ts | 40 +++++ listener/src/types/index.ts | 2 + listener/src/types/preferences.ts | 12 ++ 9 files changed, 520 insertions(+), 9 deletions(-) create mode 100644 listener/API.md create mode 100644 listener/src/api/events-server.test.ts create mode 100644 listener/src/store/preference-store.test.ts create mode 100644 listener/src/store/preference-store.ts create mode 100644 listener/src/types/preferences.ts diff --git a/listener/API.md b/listener/API.md new file mode 100644 index 0000000..3e0761e --- /dev/null +++ b/listener/API.md @@ -0,0 +1,140 @@ +# Listener Service API + +Base URL: `http://localhost:8787` (configured via `EVENTS_API_PORT`) + +--- + +## Events + +### GET /api/events + +Returns all stored contract events. + +**Query Parameters** + +| Name | Type | Required | Description | +|-------|--------|----------|------------------------------------| +| limit | number | No | Maximum number of events to return | + +**Response `200`** + +```json +{ + "count": 42, + "events": [ + { + "eventId": "string", + "contractAddress": "string", + "eventName": "string | null", + "ledger": 12345, + "type": "contract", + "topic": ["TaskCreated"], + "value": "string", + "txHash": "string", + "receivedAt": 1718640000000 + } + ] +} +``` + +--- + +## User Notification Preferences + +Preferences control which notification categories are delivered per user. Categories default to **enabled** when not explicitly set. + +### GET /api/preferences/:userId + +Returns the notification preferences for a user. + +**Path Parameters** + +| Name | Description | +|--------|--------------------| +| userId | User identifier | + +**Response `200`** + +```json +{ + "userId": "alice", + "categories": { + "discord": true + }, + "updatedAt": 1718640000000 +} +``` + +--- + +### PUT /api/preferences/:userId + +Updates one or more notification category flags for a user. Unspecified categories are preserved. + +**Path Parameters** + +| Name | Description | +|--------|--------------------| +| userId | User identifier | + +**Request Body** + +```json +{ + "categories": { + "discord": false + } +} +``` + +| Field | Type | Required | Description | +|------------|-------------------------------|----------|------------------------------------------| +| categories | `Record` | Yes | Map of category name to enabled flag | + +**Response `200`** — returns the full updated preferences object. + +```json +{ + "userId": "alice", + "categories": { + "discord": false + }, + "updatedAt": 1718640100000 +} +``` + +**Response `400`** — returned when the request body is invalid JSON or the `categories` field is missing. + +```json +{ "error": "Invalid body: expected { categories: { [key]: boolean } }" } +``` + +--- + +## Notification Categories + +| Category | Description | +|-----------|------------------------------| +| `discord` | Discord webhook notifications | + +Additional categories can be added by extending the `categories` map. + +--- + +## Per-Contract User Binding + +To apply user preferences to a specific contract's events, set `userId` in the contract address config: + +```json +{ + "CONTRACT_ADDRESSES": [ + { + "address": "CCEMX6...", + "events": ["*"], + "userId": "alice" + } + ] +} +``` + +If `userId` is omitted, the `"global"` user's preferences are applied. diff --git a/listener/src/api/events-server.test.ts b/listener/src/api/events-server.test.ts new file mode 100644 index 0000000..489aec4 --- /dev/null +++ b/listener/src/api/events-server.test.ts @@ -0,0 +1,120 @@ +import http from 'http'; +import { createEventsServer } from './events-server'; +import { preferenceStore } from '../store/preference-store'; + +jest.mock('../store/preference-store', () => { + const store = { + get: jest.fn(), + update: jest.fn(), + isCategoryEnabled: jest.fn(), + }; + return { preferenceStore: store }; +}); + +jest.mock('../store/event-registry', () => ({ + eventRegistry: { getEvents: jest.fn(() => []), count: jest.fn(() => 0) }, +})); + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const mockStore = preferenceStore as jest.Mocked; + +function request( + server: http.Server, + method: string, + path: string, + body?: object +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const port = (server.address() as { port: number }).port; + const payload = body ? JSON.stringify(body) : undefined; + const req = http.request( + { hostname: '127.0.0.1', port, path, method, + headers: { 'Content-Type': 'application/json', ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}) }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve({ status: res.statusCode!, body: JSON.parse(data) })); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +describe('Preference API endpoints', () => { + let server: http.Server; + + beforeEach((done) => { + jest.clearAllMocks(); + server = createEventsServer({ port: 0 }); + server.listen(0, '127.0.0.1', done); + }); + + afterEach((done) => { + server.close(done); + }); + + describe('GET /api/preferences/:userId', () => { + it('returns preferences for the given user', async () => { + const prefs = { userId: 'alice', categories: { discord: true }, updatedAt: 1000 }; + mockStore.get.mockReturnValue(prefs); + + const res = await request(server, 'GET', '/api/preferences/alice'); + + expect(res.status).toBe(200); + expect(res.body).toEqual(prefs); + expect(mockStore.get).toHaveBeenCalledWith('alice'); + }); + }); + + describe('PUT /api/preferences/:userId', () => { + it('updates and returns preferences', async () => { + const updated = { userId: 'alice', categories: { discord: false }, updatedAt: 2000 }; + mockStore.update.mockReturnValue(updated); + + const res = await request(server, 'PUT', '/api/preferences/alice', { + categories: { discord: false }, + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual(updated); + expect(mockStore.update).toHaveBeenCalledWith('alice', { categories: { discord: false } }); + }); + + it('returns 400 for invalid JSON body', async () => { + const port = (server.address() as { port: number }).port; + const res = await new Promise<{ status: number }>((resolve, reject) => { + const req = http.request( + { hostname: '127.0.0.1', port, path: '/api/preferences/alice', method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Content-Length': 8 } }, + (r) => { + r.resume(); + r.on('end', () => resolve({ status: r.statusCode! })); + } + ); + req.on('error', reject); + req.write('not-json'); + req.end(); + }); + expect(res.status).toBe(400); + }); + + it('returns 400 when categories field is missing', async () => { + const res = await request(server, 'PUT', '/api/preferences/alice', { foo: 'bar' }); + expect(res.status).toBe(400); + }); + }); + + describe('unknown routes', () => { + it('returns 404 for unrecognised paths', async () => { + const res = await request(server, 'GET', '/api/unknown'); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 46fad32..e152a5f 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -1,5 +1,7 @@ import http from 'http'; import { eventRegistry } from '../store/event-registry'; +import { preferenceStore } from '../store/preference-store'; +import { PreferencesUpdateInput } from '../types/preferences'; import logger from '../utils/logger'; export interface EventsServerOptions { @@ -12,7 +14,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return http.createServer((req, res) => { res.setHeader('Access-Control-Allow-Origin', corsOrigin); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { @@ -21,8 +23,10 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } - if (req.method === 'GET' && req.url?.startsWith('/api/events')) { - const url = new URL(req.url, 'http://localhost'); + const url = new URL(req.url ?? '/', 'http://localhost'); + + // GET /api/events + if (req.method === 'GET' && url.pathname.startsWith('/api/events')) { const limitParam = url.searchParams.get('limit'); const limit = limitParam ? parseInt(limitParam, 10) : undefined; const events = @@ -31,12 +35,42 @@ export function createEventsServer(options: EventsServerOptions): http.Server { : eventRegistry.getEvents(); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - count: eventRegistry.count(), - events, - }) - ); + res.end(JSON.stringify({ count: eventRegistry.count(), events })); + return; + } + + // GET /api/preferences/:userId + const getPrefsMatch = url.pathname.match(/^\/api\/preferences\/([^/]+)$/); + if (req.method === 'GET' && getPrefsMatch) { + const userId = decodeURIComponent(getPrefsMatch[1]); + const prefs = preferenceStore.get(userId); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(prefs)); + return; + } + + // PUT /api/preferences/:userId + const putPrefsMatch = url.pathname.match(/^\/api\/preferences\/([^/]+)$/); + if (req.method === 'PUT' && putPrefsMatch) { + const userId = decodeURIComponent(putPrefsMatch[1]); + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + try { + const input: PreferencesUpdateInput = JSON.parse(body); + if (!input || typeof input.categories !== 'object') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid body: expected { categories: { [key]: boolean } }' })); + return; + } + const updated = preferenceStore.update(userId, input); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(updated)); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); return; } diff --git a/listener/src/services/event-subscriber.test.ts b/listener/src/services/event-subscriber.test.ts index d33521d..3e602af 100644 --- a/listener/src/services/event-subscriber.test.ts +++ b/listener/src/services/event-subscriber.test.ts @@ -35,6 +35,12 @@ jest.mock('./discord-notification', () => ({ DiscordNotificationService: jest.fn().mockImplementation(() => mockDiscordService), })); +jest.mock('../store/preference-store', () => ({ + preferenceStore: { + isCategoryEnabled: jest.fn().mockReturnValue(true), + }, +})); + const mockLogger = logger as jest.Mocked; const contractConfig: ContractConfig = { @@ -539,4 +545,74 @@ describe('EventSubscriber', () => { ); }); }); + + describe('notification preferences gate', () => { + const discordConfig = { + webhookUrl: 'https://discord.com/api/webhooks/test/webhook', + webhookId: 'test', + }; + const configWithDiscord: Config = { ...testConfig, discord: discordConfig }; + + beforeEach(() => { + const { DiscordNotificationService } = jest.requireMock('./discord-notification'); + DiscordNotificationService.mockImplementation(() => mockDiscordService); + mockDiscordService.sendEventNotification.mockResolvedValue(true); + mockGetEvents.mockResolvedValue({ + events: [createMockEvent({ id: 'pref-event' })], + cursor: 'cursor-pref', + }); + }); + + it('skips Discord notification when discord category is disabled for the user', async () => { + const { preferenceStore } = jest.requireMock('../store/preference-store'); + preferenceStore.isCategoryEnabled.mockReturnValue(false); + + const subscriber = new EventSubscriber(configWithDiscord); + await (subscriber as any).checkForEvents(); + + expect(mockDiscordService.sendEventNotification).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Skipping Discord notification: category disabled by user preferences', + expect.objectContaining({ eventId: 'pref-event' }) + ); + }); + + it('sends Discord notification when discord category is enabled', async () => { + const { preferenceStore } = jest.requireMock('../store/preference-store'); + preferenceStore.isCategoryEnabled.mockReturnValue(true); + mockDiscordService.sendEventNotification.mockResolvedValue(true); + + const subscriber = new EventSubscriber(configWithDiscord); + await (subscriber as any).checkForEvents(); + + expect(mockDiscordService.sendEventNotification).toHaveBeenCalled(); + }); + + it('uses contractConfig.userId when present', async () => { + const { preferenceStore } = jest.requireMock('../store/preference-store'); + preferenceStore.isCategoryEnabled.mockReturnValue(true); + mockDiscordService.sendEventNotification.mockResolvedValue(true); + + const configWithUserId: Config = { + ...configWithDiscord, + contractAddresses: [{ ...contractConfig, userId: 'alice' }], + }; + + const subscriber = new EventSubscriber(configWithUserId); + await (subscriber as any).checkForEvents(); + + expect(preferenceStore.isCategoryEnabled).toHaveBeenCalledWith('alice', 'discord'); + }); + + it('defaults to "global" userId when contractConfig.userId is absent', async () => { + const { preferenceStore } = jest.requireMock('../store/preference-store'); + preferenceStore.isCategoryEnabled.mockReturnValue(true); + mockDiscordService.sendEventNotification.mockResolvedValue(true); + + const subscriber = new EventSubscriber(configWithDiscord); + await (subscriber as any).checkForEvents(); + + expect(preferenceStore.isCategoryEnabled).toHaveBeenCalledWith('global', 'discord'); + }); + }); }); diff --git a/listener/src/services/event-subscriber.ts b/listener/src/services/event-subscriber.ts index f1f4963..e8a61e4 100644 --- a/listener/src/services/event-subscriber.ts +++ b/listener/src/services/event-subscriber.ts @@ -1,6 +1,7 @@ import * as StellarSDK from '@stellar/stellar-sdk'; import { Config, ContractConfig } from '../types'; import { eventRegistry } from '../store/event-registry'; +import { preferenceStore } from '../store/preference-store'; import logger from '../utils/logger'; import { getEventName, @@ -170,6 +171,15 @@ export class EventSubscriber { }); if (this.discordService) { + const userId = contractConfig.userId ?? 'global'; + if (!preferenceStore.isCategoryEnabled(userId, 'discord')) { + logger.info('Skipping Discord notification: category disabled by user preferences', { + eventId: event.id, + userId, + }); + return; + } + const success = await this.discordService.sendEventNotification( event, contractConfig diff --git a/listener/src/store/preference-store.test.ts b/listener/src/store/preference-store.test.ts new file mode 100644 index 0000000..a64d31a --- /dev/null +++ b/listener/src/store/preference-store.test.ts @@ -0,0 +1,77 @@ +import { PreferenceStore } from './preference-store'; + +describe('PreferenceStore', () => { + let store: PreferenceStore; + + beforeEach(() => { + store = new PreferenceStore(); + }); + + describe('get', () => { + it('returns default preferences for a new user', () => { + const prefs = store.get('user-1'); + expect(prefs.userId).toBe('user-1'); + expect(prefs.categories.discord).toBe(true); + expect(typeof prefs.updatedAt).toBe('number'); + }); + + it('returns a copy so mutations do not affect stored state', () => { + const prefs = store.get('user-1'); + prefs.categories.discord = false; + expect(store.get('user-1').categories.discord).toBe(true); + }); + }); + + describe('update', () => { + it('disables a notification category', () => { + store.update('user-1', { categories: { discord: false } }); + expect(store.get('user-1').categories.discord).toBe(false); + }); + + it('re-enables a disabled category', () => { + store.update('user-1', { categories: { discord: false } }); + store.update('user-1', { categories: { discord: true } }); + expect(store.get('user-1').categories.discord).toBe(true); + }); + + it('merges categories without removing unrelated ones', () => { + store.update('user-1', { categories: { discord: true, email: true } }); + store.update('user-1', { categories: { discord: false } }); + expect(store.get('user-1').categories.email).toBe(true); + expect(store.get('user-1').categories.discord).toBe(false); + }); + + it('updates updatedAt timestamp', async () => { + const before = store.get('user-1').updatedAt; + await new Promise((r) => setTimeout(r, 5)); + store.update('user-1', { categories: { discord: false } }); + expect(store.get('user-1').updatedAt).toBeGreaterThan(before); + }); + + it('persists changes across get calls', () => { + store.update('user-2', { categories: { discord: false } }); + expect(store.get('user-2').categories.discord).toBe(false); + expect(store.get('user-2').categories.discord).toBe(false); + }); + }); + + describe('isCategoryEnabled', () => { + it('returns true for the default discord category', () => { + expect(store.isCategoryEnabled('user-1', 'discord')).toBe(true); + }); + + it('returns false after disabling the discord category', () => { + store.update('user-1', { categories: { discord: false } }); + expect(store.isCategoryEnabled('user-1', 'discord')).toBe(false); + }); + + it('returns true for an unknown category (default enabled)', () => { + expect(store.isCategoryEnabled('user-1', 'unknown-channel')).toBe(true); + }); + + it('isolates preferences between users', () => { + store.update('user-a', { categories: { discord: false } }); + expect(store.isCategoryEnabled('user-b', 'discord')).toBe(true); + }); + }); +}); diff --git a/listener/src/store/preference-store.ts b/listener/src/store/preference-store.ts new file mode 100644 index 0000000..cec143d --- /dev/null +++ b/listener/src/store/preference-store.ts @@ -0,0 +1,40 @@ +import { UserPreferences, PreferencesUpdateInput } from '../types/preferences'; + +export class PreferenceStore { + private store = new Map(); + + /** Returns preferences for userId, creating defaults if absent */ + get(userId: string): UserPreferences { + if (!this.store.has(userId)) { + const defaults: UserPreferences = { + userId, + categories: { discord: true }, + updatedAt: Date.now(), + }; + this.store.set(userId, defaults); + } + const stored = this.store.get(userId)!; + return { ...stored, categories: { ...stored.categories } }; + } + + /** Merges category updates, returns updated preferences */ + update(userId: string, input: PreferencesUpdateInput): UserPreferences { + const existing = this.get(userId); + const updated: UserPreferences = { + ...existing, + categories: { ...existing.categories, ...input.categories }, + updatedAt: Date.now(), + }; + this.store.set(userId, updated); + return { ...updated }; + } + + /** Returns true if the given category is enabled for userId */ + isCategoryEnabled(userId: string, category: string): boolean { + const prefs = this.get(userId); + // If the category has never been set, default to enabled + return prefs.categories[category] !== false; + } +} + +export const preferenceStore = new PreferenceStore(); diff --git a/listener/src/types/index.ts b/listener/src/types/index.ts index 659a4c6..0e81397 100644 --- a/listener/src/types/index.ts +++ b/listener/src/types/index.ts @@ -1,6 +1,8 @@ export interface ContractConfig { address: string; events: string[]; + /** Optional user ID for per-user notification preference gating */ + userId?: string; } export interface DiscordConfig { diff --git a/listener/src/types/preferences.ts b/listener/src/types/preferences.ts new file mode 100644 index 0000000..482a272 --- /dev/null +++ b/listener/src/types/preferences.ts @@ -0,0 +1,12 @@ +export type NotificationCategory = 'discord' | string; + +export interface UserPreferences { + userId: string; + /** Map of notification category → enabled flag */ + categories: Record; + updatedAt: number; +} + +export interface PreferencesUpdateInput { + categories: Record; +}