Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions listener/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions listener/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
14 changes: 11 additions & 3 deletions listener/src/api/events-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
86 changes: 83 additions & 3 deletions listener/src/api/notifications-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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();
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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(
Expand All @@ -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');
Expand Down
14 changes: 10 additions & 4 deletions listener/src/services/notification-history.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<PaginatedHistoryResponse> {
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
Expand Down Expand Up @@ -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 });
Expand Down
79 changes: 79 additions & 0 deletions listener/src/utils/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading