diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 7feeac98..8d7f0012 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -4,7 +4,7 @@ use soroban_sdk::{contractevent, contracttype, Address, BytesN, String}; /// /// Off-chain consumers (listeners, indexers, dashboards) often only care about a /// subset of the events the contract emits. Each event carries its category as a -/// trailing, indexed event topic so consumers can subscribe to or filter out +/// trailing, indexed event topic so consumers can subscribe to or filter out /// whole categories without having to decode the event payload first. /// /// # Backward compatibility @@ -31,7 +31,7 @@ pub enum NotificationCategory { /// /// Off-chain consumers (alerting, dashboards, paging) often route notifications /// by priority rather than (or in addition to) category. Each event carries its -/// priority as a trailing, indexed event topic so consumers can subscribe to +/// priority as a trailing, indexed event topic so consumers can subscribe to /// or page on high-priority notifications without decoding the payload. /// /// # Backward compatibility diff --git a/listener/API.md b/listener/API.md index b4657f66..352a9263 100644 --- a/listener/API.md +++ b/listener/API.md @@ -292,6 +292,64 @@ Returns aggregate statistics about the scheduled-notification queue. --- +## Notification Delivery History + +### GET /api/notifications/history + +Returns paginated delivery execution records from `notification_execution_log`. + +**Query Parameters** + +| Name | Type | Required | Description | +|-----------|--------|----------|-------------------------------------------------------------------| +| limit | number | No | Maximum records per page (default `20`, max `100`) | +| offset | number | No | Number of records to skip (default `0`) | +| status | string | No | Filter by execution status: `SUCCESS`, `FAILED`, or `RETRY` | +| startDate | string | No | ISO 8601 lower bound on `execution_time` (inclusive) | +| endDate | string | No | ISO 8601 upper bound on `execution_time` (inclusive) | + +**Response `200`** + +```json +{ + "records": [ + { + "id": 1, + "scheduledNotificationId": 42, + "executionAttempt": 1, + "executionTime": "2024-06-20T15:00:00.000Z", + "status": "SUCCESS", + "errorMessage": null, + "responseDuration": 120 + } + ], + "total": 5, + "itemCount": 5, + "totalPages": 3, + "limit": 2, + "offset": 0 +} +``` + +| Field | Type | Description | +|-------------|--------|-----------------------------------------------------------------------------| +| records | array | Execution log entries for the current page | +| total | number | Total matching records (preserved for backward compatibility; same value as `itemCount`) | +| itemCount | number | Total number of records matching the query filters | +| totalPages | number | Total pages available at the requested `limit` (`0` when `itemCount` is `0`) | +| limit | number | Effective page size applied to the query | +| offset | number | Number of records skipped before this page | + +Existing clients that read `total`, `limit`, `offset`, and `records` continue to work unchanged. New clients should prefer `itemCount` and `totalPages` for pagination UI. + +**Response `500`** — database read failure + +```json +{ "error": "SQLITE_ERROR: ..." } +``` + +--- + ## Webhooks ### POST /api/webhooks diff --git a/listener/package.json b/listener/package.json index 26360a90..4d6bcdaf 100644 --- a/listener/package.json +++ b/listener/package.json @@ -7,6 +7,8 @@ "build": "node ./node_modules/typescript/bin/tsc", "start": "node dist/index.js", "test": "node ./node_modules/jest/bin/jest.js", + "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", + "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "migrate": "ts-node src/scripts/migrate-db.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 197e189e..d38da074 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -156,7 +156,15 @@ export function createEventsServer(options: EventsServerOptions): http.Server { res.setHeader('X-Request-Id', requestId); res.setHeader('X-Correlation-Id', correlationId); - if (rateLimiter) { + const url = new URL(req.url ?? '/', 'http://localhost'); + + // The rate-limit metrics endpoint is an observability route and must stay + // reachable even after a client exhausts its quota — otherwise callers + // can't read the very metrics that explain why they are being throttled. + const isRateLimitExempt = + req.method === 'GET' && url.pathname === '/api/rate-limit/metrics'; + + if (rateLimiter && !isRateLimitExempt) { const allowed = await rateLimiter.handle(req, res as any); if (!allowed) return; } @@ -167,8 +175,6 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } - const url = new URL(req.url ?? '/', 'http://localhost'); - // GET /health if (req.method === 'GET' && url.pathname === '/health') { buildHealthResponse(options).then((health) => { @@ -484,7 +490,9 @@ export function createEventsServer(options: EventsServerOptions): http.Server { const userId = decodeURIComponent(getPrefsMatch[1]); logger.info('Handling GET /api/preferences/:userId', { requestId, correlationId, userId }); const prefs = preferenceStore.get(userId); + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(prefs)); + return; } // PUT /api/preferences/:userId diff --git a/listener/src/api/notifications-history.test.ts b/listener/src/api/notifications-history.test.ts index a3077108..65197d4c 100644 --- a/listener/src/api/notifications-history.test.ts +++ b/listener/src/api/notifications-history.test.ts @@ -63,6 +63,14 @@ describe('GET /api/notifications/history', () => { await db.close(); }); + beforeEach(async () => { + await db.run('DELETE FROM notification_execution_log'); + await db.run('DELETE FROM scheduled_notifications'); + await db.run( + "DELETE FROM sqlite_sequence WHERE name IN ('scheduled_notifications', 'notification_execution_log')" + ); + }); + afterEach(async () => { if (server) await closeServer(server); }); @@ -74,6 +82,8 @@ describe('GET /api/notifications/history', () => { expect(status).toBe(200); expect((body as any).records).toEqual([]); expect((body as any).total).toBe(0); + expect((body as any).itemCount).toBe(0); + expect((body as any).totalPages).toBe(0); expect((body as any).limit).toBeDefined(); expect((body as any).offset).toBeDefined(); }); @@ -109,6 +119,8 @@ describe('GET /api/notifications/history', () => { expect(status).toBe(200); expect((body as any).records.length).toBe(2); expect((body as any).total).toBe(5); + expect((body as any).itemCount).toBe(5); + expect((body as any).totalPages).toBe(3); expect((body as any).limit).toBe(2); expect((body as any).offset).toBe(0); }); @@ -146,12 +158,63 @@ describe('GET /api/notifications/history', () => { ); expect(status).toBe(200); - expect((body as any).records.length).toBeGreaterThan(0); + expect((body as any).records.length).toBe(1); + expect((body as any).itemCount).toBe(1); + expect((body as any).totalPages).toBe(1); + expect((body as any).total).toBe((body as any).itemCount); (body as any).records.forEach((record: any) => { expect(record.status).toBe('SUCCESS'); }); }); + it('normalizes negative limit and offset query params', async () => { + server = await startServer(BASE_OPTIONS); + + const { status, body } = await makeRequest( + server, + '/api/notifications/history?limit=-5&offset=-10' + ); + + expect(status).toBe(200); + expect((body as any).limit).toBe(1); + expect((body as any).offset).toBe(0); + expect((body as any).itemCount).toBe(0); + expect((body as any).totalPages).toBe(0); + }); + + it('returns pagination metadata on the last partial page', async () => { + server = await startServer(BASE_OPTIONS); + + for (let i = 0; i < 3; 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'] + ); + } + + for (let i = 1; i <= 3; 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=2' + ); + + expect(status).toBe(200); + expect((body as any).records.length).toBe(1); + expect((body as any).itemCount).toBe(3); + expect((body as any).totalPages).toBe(2); + expect((body as any).total).toBe((body as any).itemCount); + }); + it('enforces maximum limit of 100', async () => { server = await startServer(BASE_OPTIONS); const { status, body } = await makeRequest( @@ -162,11 +225,28 @@ describe('GET /api/notifications/history', () => { expect(status).toBe(200); expect((body as any).limit).toBeLessThanOrEqual(100); }); +}); + +describe('GET /api/notifications/history database failures', () => { + 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 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'); diff --git a/listener/src/services/notification-history.ts b/listener/src/services/notification-history.ts index 142de3b7..215adf9c 100644 --- a/listener/src/services/notification-history.ts +++ b/listener/src/services/notification-history.ts @@ -1,5 +1,6 @@ import { getDatabase } from '../database/database'; import logger from '../utils/logger'; +import { buildPaginationMetadata, normalizePaginationParams } from '../utils/pagination'; export interface NotificationHistoryRecord { id: number; @@ -24,14 +25,15 @@ export interface PaginatedHistoryResponse { total: number; limit: number; offset: number; + itemCount: number; + totalPages: 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; + const { limit, offset } = normalizePaginationParams(options.limit, options.offset); try { // Build WHERE clause @@ -88,11 +90,15 @@ export class NotificationHistoryService { offset, }); + const pagination = buildPaginationMetadata(total, limit, offset); + return { records, total, - limit, - offset, + limit: pagination.limit, + offset: pagination.offset, + itemCount: pagination.itemCount, + totalPages: pagination.totalPages, }; } catch (error) { logger.error('Failed to retrieve notification history', { error }); diff --git a/listener/src/utils/pagination.test.ts b/listener/src/utils/pagination.test.ts new file mode 100644 index 00000000..1771b52b --- /dev/null +++ b/listener/src/utils/pagination.test.ts @@ -0,0 +1,79 @@ +import { + buildPaginationMetadata, + calculateTotalPages, + normalizePaginationParams, +} from './pagination'; + +describe('normalizePaginationParams', () => { + it('applies defaults when params are missing', () => { + expect(normalizePaginationParams()).toEqual({ limit: 20, offset: 0 }); + }); + + it('clamps limit to the configured maximum', () => { + expect(normalizePaginationParams(200, 0)).toEqual({ limit: 100, offset: 0 }); + }); + + it('rejects negative and non-finite values', () => { + expect(normalizePaginationParams(-5, -10)).toEqual({ limit: 1, offset: 0 }); + expect(normalizePaginationParams(Number.NaN, Number.NaN)).toEqual({ limit: 20, offset: 0 }); + }); +}); + +describe('calculateTotalPages', () => { + it('returns 0 when item count is zero', () => { + expect(calculateTotalPages(0, 20)).toBe(0); + }); + + it('returns 0 when limit is zero or negative', () => { + expect(calculateTotalPages(10, 0)).toBe(0); + expect(calculateTotalPages(10, -5)).toBe(0); + }); + + it('returns 1 when items fit within a single page', () => { + expect(calculateTotalPages(1, 20)).toBe(1); + expect(calculateTotalPages(20, 20)).toBe(1); + }); + + it('rounds up when items spill into another page', () => { + expect(calculateTotalPages(21, 20)).toBe(2); + expect(calculateTotalPages(5, 2)).toBe(3); + }); +}); + +describe('buildPaginationMetadata', () => { + it('returns zeroed metadata for empty result sets', () => { + expect(buildPaginationMetadata(0, 20, 0)).toEqual({ + itemCount: 0, + totalPages: 0, + limit: 20, + offset: 0, + }); + }); + + it('normalizes negative inputs to safe boundaries', () => { + expect(buildPaginationMetadata(-3, 0, -10)).toEqual({ + itemCount: 0, + totalPages: 0, + limit: 1, + offset: 0, + }); + }); + + it('includes total pages and item count for multi-page results', () => { + expect(buildPaginationMetadata(5, 2, 2)).toEqual({ + itemCount: 5, + totalPages: 3, + limit: 2, + offset: 2, + }); + }); + + it('preserves offset beyond the final page', () => { + expect(buildPaginationMetadata(5, 2, 10)).toEqual({ + itemCount: 5, + totalPages: 3, + limit: 2, + offset: 10, + }); + }); +}); diff --git a/listener/src/utils/pagination.ts b/listener/src/utils/pagination.ts new file mode 100644 index 00000000..8df07eff --- /dev/null +++ b/listener/src/utils/pagination.ts @@ -0,0 +1,58 @@ +export interface PaginationMetadata { + itemCount: number; + totalPages: number; + limit: number; + offset: number; +} + +export interface PaginationQueryParams { + limit: number; + offset: number; +} + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +/** + * Normalizes limit/offset query params before database pagination. + */ +export function normalizePaginationParams( + limit?: number, + offset?: number, +): PaginationQueryParams { + const parsedLimit = Number.isFinite(limit) ? Math.trunc(limit!) : DEFAULT_PAGE_SIZE; + const parsedOffset = Number.isFinite(offset) ? Math.trunc(offset!) : 0; + + return { + limit: Math.min(Math.max(1, parsedLimit), MAX_PAGE_SIZE), + offset: Math.max(0, parsedOffset), + }; +} + +/** + * Builds standardized pagination metadata for offset/limit list responses. + */ +export function buildPaginationMetadata( + itemCount: number, + limit: number, + offset: number, +): PaginationMetadata { + const normalizedItemCount = Math.max(0, itemCount); + const normalizedLimit = Math.max(1, limit); + const normalizedOffset = Math.max(0, offset); + + return { + itemCount: normalizedItemCount, + totalPages: calculateTotalPages(normalizedItemCount, normalizedLimit), + limit: normalizedLimit, + offset: normalizedOffset, + }; +} + +export function calculateTotalPages(itemCount: number, limit: number): number { + if (itemCount <= 0 || limit <= 0) { + return 0; + } + + return Math.ceil(itemCount / limit); +}