diff --git a/listener/package-lock.json b/listener/package-lock.json index c4ab623..21d29fc 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -60,6 +60,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1664,6 +1665,7 @@ "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -2203,6 +2205,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3538,6 +3541,29 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3853,6 +3879,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5014,6 +5041,74 @@ "node": ">=10" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6214,6 +6309,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6307,6 +6403,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index a36b7ec..76fdb65 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -6,6 +6,8 @@ import { PreferencesUpdateInput } from '../types/preferences'; import { NotificationAPI } from '../services/notification-api'; import { NotificationType } from '../types/scheduled-notification'; import logger from '../utils/logger'; +import { generateRequestId } from '../utils/request-id'; +import { NotificationHistoryService } from '../services/notification-history'; import { generateRequestId, resolveCorrelationId } from '../utils/request-id'; import { verifySignature, @@ -141,6 +143,7 @@ async function buildHealthResponse(options: EventsServerOptions): Promise { @@ -397,6 +400,55 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // Get notification delivery history endpoint + if (req.method === 'GET' && req.url?.startsWith('/api/notifications/history')) { + const url = new URL(req.url, 'http://localhost'); + const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!, 10) : undefined; + const offset = url.searchParams.get('offset') ? parseInt(url.searchParams.get('offset')!, 10) : undefined; + const status = url.searchParams.get('status') as 'SUCCESS' | 'FAILED' | 'RETRY' | null; + const startDate = url.searchParams.get('startDate'); + const endDate = url.searchParams.get('endDate'); + + logger.info('Handling GET /api/notifications/history', { + requestId, + limit, + offset, + status, + startDate, + endDate, + }); + + historyService.getHistory({ + limit, + offset, + status: status || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + }) + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + + logger.info('GET /api/notifications/history complete', { + requestId, + returned: result.records.length, + total: result.total, + durationMs: Date.now() - startTime, + }); + }) + .catch((error) => { + logger.error('Failed to retrieve notification history', { error, requestId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + }); + return; + } + + logger.warn('Unhandled request', { + requestId, + method: req.method, + url: req.url, + }); // GET /api/preferences/:userId const getPrefsMatch = url.pathname.match(/^\/api\/preferences\/([^/]+)$/); if (req.method === 'GET' && getPrefsMatch) { @@ -454,4 +506,4 @@ export function startEventsServer(options: EventsServerOptions): http.Server { logger.info('Events API server listening', { port: options.port }); }); return server; -} +} \ No newline at end of file diff --git a/listener/src/api/notifications-history.test.ts b/listener/src/api/notifications-history.test.ts new file mode 100644 index 0000000..a307710 --- /dev/null +++ b/listener/src/api/notifications-history.test.ts @@ -0,0 +1,175 @@ +import http from 'http'; +import { createEventsServer } from './events-server'; +import { Database, getDatabase } from '../database/database'; + +jest.mock('@stellar/stellar-sdk', () => ({ + rpc: { + Server: jest.fn().mockImplementation(() => ({ + getHealth: jest.fn().mockResolvedValue({ status: 'healthy' }), + })), + }, +})); + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +const BASE_OPTIONS = { + port: 0, + stellarRpcUrl: 'https://soroban-testnet.stellar.org:443', +}; + +function makeRequest( + server: http.Server, + path: string +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const addr = server.address() as { port: number }; + const req = http.request( + { host: '127.0.0.1', port: addr.port, path, method: 'GET' }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve({ status: res.statusCode!, body: JSON.parse(data) })); + } + ); + req.on('error', reject); + req.end(); + }); +} + +function startServer(options: Parameters[0]): Promise { + return new Promise((resolve) => { + const server = createEventsServer(options); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +function closeServer(server: http.Server): Promise { + return new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +} + +describe('GET /api/notifications/history', () => { + let server: http.Server; + let db: Database; + + beforeAll(async () => { + db = getDatabase(':memory:'); + await db.initialize(); + }); + + afterAll(async () => { + await db.close(); + }); + + afterEach(async () => { + if (server) await closeServer(server); + }); + + it('returns empty history with correct structure', async () => { + server = await startServer(BASE_OPTIONS); + const { status, body } = await makeRequest(server, '/api/notifications/history'); + + expect(status).toBe(200); + expect((body as any).records).toEqual([]); + expect((body as any).total).toBe(0); + expect((body as any).limit).toBeDefined(); + expect((body as any).offset).toBeDefined(); + }); + + it('supports pagination with limit and offset', async () => { + server = await startServer(BASE_OPTIONS); + + // Insert parent records first + for (let i = 0; i < 5; i++) { + await db.run( + `INSERT INTO scheduled_notifications + (payload, notification_type, target_recipient, execute_at, status) + VALUES (?, ?, ?, ?, ?)`, + [JSON.stringify({ test: true }), 'discord', 'test_user', new Date().toISOString(), 'COMPLETED'] + ); + } + + // Insert execution log records + for (let i = 1; i <= 5; i++) { + await db.run( + `INSERT INTO notification_execution_log + (scheduled_notification_id, execution_attempt, execution_time, status, duration_ms) + VALUES (?, ?, ?, ?, ?)`, + [i, 1, new Date().toISOString(), 'SUCCESS', 100] + ); + } + + const { status, body } = await makeRequest( + server, + '/api/notifications/history?limit=2&offset=0' + ); + + expect(status).toBe(200); + expect((body as any).records.length).toBe(2); + expect((body as any).total).toBe(5); + expect((body as any).limit).toBe(2); + expect((body as any).offset).toBe(0); + }); + + it('filters by status', async () => { + server = await startServer(BASE_OPTIONS); + + // Insert parent records + for (let i = 0; i < 2; i++) { + await db.run( + `INSERT INTO scheduled_notifications + (payload, notification_type, target_recipient, execute_at, status) + VALUES (?, ?, ?, ?, ?)`, + [JSON.stringify({ test: true }), 'discord', 'test_user', new Date().toISOString(), 'COMPLETED'] + ); + } + + // Insert mixed status data + await db.run( + `INSERT INTO notification_execution_log + (scheduled_notification_id, execution_attempt, execution_time, status, duration_ms) + VALUES (?, ?, ?, ?, ?)`, + [1, 1, new Date().toISOString(), 'SUCCESS', 100] + ); + await db.run( + `INSERT INTO notification_execution_log + (scheduled_notification_id, execution_attempt, execution_time, status, duration_ms) + VALUES (?, ?, ?, ?, ?)`, + [2, 1, new Date().toISOString(), 'FAILED', 200] + ); + + const { status, body } = await makeRequest( + server, + '/api/notifications/history?status=SUCCESS' + ); + + expect(status).toBe(200); + expect((body as any).records.length).toBeGreaterThan(0); + (body as any).records.forEach((record: any) => { + expect(record.status).toBe('SUCCESS'); + }); + }); + + it('enforces maximum limit of 100', async () => { + server = await startServer(BASE_OPTIONS); + const { status, body } = await makeRequest( + server, + '/api/notifications/history?limit=200' + ); + + expect(status).toBe(200); + expect((body as any).limit).toBeLessThanOrEqual(100); + }); + + it('returns 500 on database error', async () => { + server = await startServer(BASE_OPTIONS); + + // Close database to cause error + await db.close(); + + const { status } = await makeRequest(server, '/api/notifications/history'); + expect(status).toBe(500); + }); +}); \ No newline at end of file diff --git a/listener/src/services/notification-history.ts b/listener/src/services/notification-history.ts new file mode 100644 index 0000000..142de3b --- /dev/null +++ b/listener/src/services/notification-history.ts @@ -0,0 +1,102 @@ +import { getDatabase } from '../database/database'; +import logger from '../utils/logger'; + +export interface NotificationHistoryRecord { + id: number; + scheduledNotificationId: number; + executionAttempt: number; + executionTime: string; + status: 'SUCCESS' | 'FAILED' | 'RETRY'; + errorMessage: string | null; + responseDuration: number | null; +} + +export interface HistoryQueryOptions { + limit?: number; + offset?: number; + status?: 'SUCCESS' | 'FAILED' | 'RETRY'; + startDate?: string; + endDate?: string; +} + +export interface PaginatedHistoryResponse { + records: NotificationHistoryRecord[]; + total: number; + limit: number; + offset: number; +} + +export class NotificationHistoryService { + private db = getDatabase(); + + async getHistory(options: HistoryQueryOptions): Promise { + const limit = Math.min(options.limit || 20, 100); + const offset = options.offset || 0; + + try { + // Build WHERE clause + const conditions: string[] = []; + const params: any[] = []; + + if (options.status) { + conditions.push('status = ?'); + params.push(options.status); + } + + if (options.startDate) { + conditions.push('execution_time >= ?'); + params.push(options.startDate); + } + + if (options.endDate) { + conditions.push('execution_time <= ?'); + params.push(options.endDate); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countSql = `SELECT COUNT(*) as count FROM notification_execution_log ${whereClause}`; + const countResult = await this.db.get<{ count: number }>(countSql, params); + const total = countResult?.count || 0; + + // Get paginated records + const sql = ` + SELECT + id, + scheduled_notification_id as scheduledNotificationId, + execution_attempt as executionAttempt, + execution_time as executionTime, + status, + error_message as errorMessage, + duration_ms as responseDuration + FROM notification_execution_log + ${whereClause} + ORDER BY execution_time DESC + LIMIT ? OFFSET ? + `; + + const records = await this.db.all( + sql, + [...params, limit, offset] + ); + + logger.info('Notification history retrieved', { + total, + returned: records.length, + limit, + offset, + }); + + return { + records, + total, + limit, + offset, + }; + } catch (error) { + logger.error('Failed to retrieve notification history', { error }); + throw error; + } + } +} \ No newline at end of file