diff --git a/.kiro/specs/audit-logging-system/.config.kiro b/.kiro/specs/audit-logging-system/.config.kiro new file mode 100644 index 0000000..66da147 --- /dev/null +++ b/.kiro/specs/audit-logging-system/.config.kiro @@ -0,0 +1 @@ +{"specId": "audit-logging-system", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/audit-logging-system/design.md b/.kiro/specs/audit-logging-system/design.md new file mode 100644 index 0000000..8721d2c --- /dev/null +++ b/.kiro/specs/audit-logging-system/design.md @@ -0,0 +1,95 @@ +# Design Document + +## Overview + +This design implements a comprehensive audit logging system for recording notification lifecycle events with immutability and queryability guarantees. + +## Architecture + +### Data Model + +```typescript +interface AuditLogEntry { + entryId: string; // Unique identifier + notificationId: string; // Reference to notification + timestamp: number; // Unix timestamp + eventType: EventType; // CREATION, DELIVERY_ATTEMPT, etc. + actor: string; // Address or identifier + status: 'success' | 'failure'; // Operation status + metadata: { + recipient?: string; + channel?: string; + errorReason?: string; + retryCount?: number; + [key: string]: any; // Flexible for event-specific data + }; + createdAt: number; // When log was created + immutable: true; // Flag indicating immutability +} + +enum EventType { + CREATION = 'CREATION', + DELIVERY_ATTEMPT = 'DELIVERY_ATTEMPT', + DELIVERY_SUCCESS = 'DELIVERY_SUCCESS', + DELIVERY_FAILURE = 'DELIVERY_FAILURE', + ACKNOWLEDGMENT = 'ACKNOWLEDGMENT' +} +``` + +### Storage Strategy + +1. **Primary Storage**: Database/persistent store for all audit logs +2. **Indexing**: Create indexes on notificationId, recipient, eventType, timestamp +3. **Archival**: Move logs older than retention period to archive +4. **Performance**: Use read-optimized queries for audit log retrieval + +### Query Interface + +```typescript +interface AuditLogQuery { + notificationId?: string; + recipient?: string; + eventType?: EventType | EventType[]; + dateRange?: { + startTime: number; + endTime: number; + }; + actor?: string; + limit?: number; // Default 100, max 1000 + offset?: number; // For pagination +} + +interface QueryResult { + entries: AuditLogEntry[]; + total: number; + hasMore: boolean; +} +``` + +### Integration Points + +1. **Event Subscriber**: Log CREATION events +2. **Discord Service**: Log DELIVERY_ATTEMPT, DELIVERY_SUCCESS, DELIVERY_FAILURE +3. **Notification Expiration**: Log EXPIRATION events +4. **API Endpoints**: Provide query interface for clients + +### Immutability Enforcement + +- No UPDATE operations on audit logs +- DELETE only through archive/purge administrative functions +- Logs stored in append-only structure +- Database constraints to prevent modification + +## Performance Considerations + +- Indexes on query fields (notificationId, recipient, eventType, timestamp) +- Pagination for large queries +- Cache frequently accessed audit logs +- Archive old logs to separate storage + +## Compliance and Retention + +- Default retention: 365 days +- Configurable retention policy +- Audit trails for who accessed what logs +- Compliance reporting endpoints \ No newline at end of file diff --git a/.kiro/specs/audit-logging-system/requirements.md b/.kiro/specs/audit-logging-system/requirements.md new file mode 100644 index 0000000..7e75e1f --- /dev/null +++ b/.kiro/specs/audit-logging-system/requirements.md @@ -0,0 +1,97 @@ +# Requirements Document + +## Introduction + +This feature creates an audit logging system that records notification lifecycle events for compliance and operational monitoring, enabling complete visibility into delivery attempts and outcomes. + +## Glossary + +- **Audit Log**: Immutable record of notification lifecycle events +- **Lifecycle Event**: Creation, delivery attempt, delivery failure, acknowledgment +- **Audit Record**: Single entry in the audit log with timestamp and event details +- **Query Endpoint**: API or function to retrieve and filter audit logs +- **Immutable**: Cannot be modified or deleted after creation +- **Compliance**: Meeting regulatory and operational requirements + +## Requirements + +### Requirement 1: Audit Event Schema + +**User Story:** As a compliance officer, I want a well-defined audit event schema, so that all events are recorded consistently. + +#### Acceptance Criteria + +1. THE audit event schema SHALL include: eventId, notificationId, timestamp, eventType, actor, status, metadata +2. THE eventType SHALL be one of: CREATION, DELIVERY_ATTEMPT, DELIVERY_SUCCESS, DELIVERY_FAILURE, ACKNOWLEDGMENT +3. THE actor field SHALL identify who/what triggered the event +4. THE metadata field SHALL be flexible JSON object for event-specific data +5. ALL fields SHALL be optional except eventId, timestamp, and eventType + +### Requirement 2: Creation Event Logging + +**User Story:** As an auditor, I want to see when notifications are created, so that I can track notification lifecycle start. + +#### Acceptance Criteria + +1. WHEN a notification is created, THE system SHALL log a CREATION event +2. THE CREATION event SHALL include: notificationId, creator address, recipient, title, content +3. THE CREATION event SHALL be logged before notification is marked active +4. THE event SHALL not be lost even if creation partially fails + +### Requirement 3: Delivery Attempt Logging + +**User Story:** As an operations manager, I want to see all delivery attempts, so that I can troubleshoot delivery issues. + +#### Acceptance Criteria + +1. WHEN delivery is attempted, THE system SHALL log a DELIVERY_ATTEMPT event +2. THE DELIVERY_ATTEMPT event SHALL include: notificationId, recipient, channel (Discord, etc.), timestamp +3. IF delivery succeeds, THE system SHALL log DELIVERY_SUCCESS event +4. IF delivery fails, THE system SHALL log DELIVERY_FAILURE event with error reason +5. EACH delivery attempt SHALL be logged independently + +### Requirement 4: Failure Logging + +**User Story:** As a support engineer, I want detailed failure logs, so that I can diagnose delivery problems. + +#### Acceptance Criteria + +1. WHEN delivery fails, THE system SHALL log failure reason/error message +2. THE failure log SHALL include: error type, error message, retry count, next retry time (if applicable) +3. RETRIES SHALL be logged as separate events +4. FAILURE logs SHALL be retained for troubleshooting + +### Requirement 5: Acknowledgment Logging + +**User Story:** As a product manager, I want to see when notifications are acknowledged, so that I can track user engagement. + +#### Acceptance Criteria + +1. WHEN a notification is acknowledged, THE system SHALL log an ACKNOWLEDGMENT event +2. THE ACKNOWLEDGMENT event SHALL include: notificationId, recipient, timestamp +3. MULTIPLE acknowledgments for same notification SHALL be supported +4. ACKNOWLEDGMENT timing relative to delivery SHALL be trackable + +### Requirement 6: Query Endpoints + +**User Story:** As a system user, I want to query audit logs, so that I can analyze notification history. + +#### Acceptance Criteria + +1. THE system SHALL provide query endpoint for retrieving audit logs +2. QUERIES SHALL support filtering by: notificationId, recipient, eventType, dateRange, actor +3. QUERIES SHALL return results in chronological order +4. QUERIES SHALL support pagination for large result sets +5. QUERIES SHALL be fast (sub-second response times) + +### Requirement 7: Immutability and Retention + +**User Story:** As a compliance manager, I want audit logs to be immutable, so that historical records cannot be tampered with. + +#### Acceptance Criteria + +1. AUDIT logs SHALL NOT be modifiable or deletable after creation +2. AUDIT logs SHALL be retained for minimum 365 days +3. ARCHIVED logs older than retention period MAY be removed +4. RETENTION policy SHALL be configurable +5. DELETION of logs SHALL only be allowed through privileged administrative function \ No newline at end of file diff --git a/.kiro/specs/audit-logging-system/tasks.md b/.kiro/specs/audit-logging-system/tasks.md new file mode 100644 index 0000000..2ada855 --- /dev/null +++ b/.kiro/specs/audit-logging-system/tasks.md @@ -0,0 +1,291 @@ +# Implementation Plan: audit-logging-system + +## Overview + +This implementation plan creates a comprehensive audit logging system for recording notification lifecycle events with queryability and immutability guarantees. + +## Tasks + +- [ ] 1. Define audit log data structures + - [ ] 1.1 Create AuditLogEntry interface + - Fields: entryId, notificationId, timestamp, eventType, actor, status, metadata + - _Requirements: 1.1, 1.2_ + + - [ ] 1.2 Create EventType enum + - Values: CREATION, DELIVERY_ATTEMPT, DELIVERY_SUCCESS, DELIVERY_FAILURE, ACKNOWLEDGMENT + - _Requirements: 1.2_ + + - [ ] 1.3 Create AuditLogQuery interface for filtering + - Fields: notificationId, recipient, eventType, dateRange, actor, limit, offset + - _Requirements: 6.2_ + + - [ ] 1.4 Create QueryResult interface + - Fields: entries, total, hasMore + - _Requirements: 6.2, 6.4_ + +- [ ] 2. Implement audit log storage + - [ ] 2.1 Create database schema for audit logs + - Table/collection structure + - Indexes on query fields + - _Requirements: 6.1, 6.5_ + + - [ ] 2.2 Implement append-only storage pattern + - Ensure logs cannot be modified + - Enforce immutability at storage level + - _Requirements: 7.1, 7.2_ + + - [ ] 2.3 Configure retention policy + - Default 365 days + - Configurable per deployment + - _Requirements: 7.2, 7.4_ + + - [ ] 2.4 Implement archival mechanism + - Move old logs to archive storage + - Maintain query access to archived logs + - _Requirements: 7.3_ + +- [ ] 3. Implement AuditLogger service + - [ ] 3.1 Create AuditLogger class + - Methods: logCreation(), logDeliveryAttempt(), logDeliverySuccess(), logDeliveryFailure(), logAcknowledgment() + - Persist all events to database + - _Requirements: 2.1, 3.1, 4.1, 5.1_ + + - [ ] 3.2 Implement logCreation() method + - Accept: notificationId, creator, recipient, title, content + - Log CREATION event with metadata + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [ ] 3.3 Implement logDeliveryAttempt() method + - Accept: notificationId, recipient, channel + - Log DELIVERY_ATTEMPT event + - _Requirements: 3.1, 3.2, 3.3_ + + - [ ] 3.4 Implement logDeliverySuccess() method + - Accept: notificationId, recipient, channel + - Log DELIVERY_SUCCESS event + - _Requirements: 3.3, 3.4_ + + - [ ] 3.5 Implement logDeliveryFailure() method + - Accept: notificationId, recipient, channel, errorReason, retryCount + - Log DELIVERY_FAILURE event + - _Requirements: 3.4, 4.1, 4.2, 4.3, 4.4_ + + - [ ] 3.6 Implement logAcknowledgment() method + - Accept: notificationId, recipient + - Log ACKNOWLEDGMENT event + - _Requirements: 5.1, 5.2, 5.3_ + +- [ ] 4. Implement query interface + - [ ] 4.1 Create queryAuditLogs() function + - Accept AuditLogQuery parameters + - Return QueryResult with entries and pagination + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 4.2 Implement filtering by notificationId + - Query all events for a specific notification + - _Requirements: 6.2_ + + - [ ] 4.3 Implement filtering by recipient + - Query all events for a specific recipient + - _Requirements: 6.2_ + + - [ ] 4.4 Implement filtering by eventType + - Query events of specific type(s) + - Support multiple types in single query + - _Requirements: 6.2_ + + - [ ] 4.5 Implement filtering by dateRange + - Query events within time period + - _Requirements: 6.2_ + + - [ ] 4.6 Implement filtering by actor + - Query events by who triggered them + - _Requirements: 6.2_ + + - [ ] 4.7 Implement pagination support + - Support limit and offset parameters + - Return hasMore flag for client + - _Requirements: 6.4_ + +- [ ] 5. Integrate logging into event processing + - [ ] 5.1 Update EventSubscriber to log CREATION + - Call auditLogger.logCreation() when processing new notification + - _Requirements: 2.1, 2.2_ + + - [ ] 5.2 Update EventSubscriber to log failures + - Log when event processing fails + - Include error details + - _Requirements: 4.1, 4.2_ + + - [ ] 5.3 Update DiscordNotificationService to log delivery attempts + - Log DELIVERY_ATTEMPT before sending + - Log DELIVERY_SUCCESS if successful + - Log DELIVERY_FAILURE if unsuccessful + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + + - [ ] 5.4 Add notification expiration logging + - Log when notifications expire + - Include expiration details in metadata + - _Requirements: 2.1_ + +- [ ] 6. Create API endpoints for audit queries + - [ ] 6.1 Create GET /audit-logs endpoint + - Query audit logs with filters + - Support query parameters: notificationId, recipient, eventType, startTime, endTime, limit, offset + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 6.2 Create GET /audit-logs/:notificationId endpoint + - Get all audit logs for a specific notification + - Return full lifecycle + - _Requirements: 6.1, 6.2, 6.3_ + + - [ ] 6.3 Create GET /audit-logs/recipient/:recipient endpoint + - Get all audit logs for a specific recipient + - _Requirements: 6.1, 6.2_ + + - [ ] 6.4 Create GET /audit-logs/stats endpoint + - Return audit log statistics + - Include: total events, events by type, date range, etc. + - _Requirements: 6.1_ + +- [ ] 7. Implement immutability guarantees + - [ ] 7.1 Add database constraints to prevent modification + - Use NOT NULL constraints on immutable field + - Add trigger to prevent UPDATE operations + - _Requirements: 7.1_ + + - [ ] 7.2 Implement access control for deletion + - Only administrators can purge old logs + - Require authorization for deletion + - _Requirements: 7.5_ + + - [ ] 7.3 Add audit trail for admin actions + - Log who accessed audit logs and when + - _Requirements: 7.1_ + +- [ ] 8. Create unit tests + - [ ] 8.1 Create audit-logger.test.ts + - Test each logging method + - Test event creation with correct metadata + - _Requirements: All_ + + - [ ] 8.2 Test logCreation() + - Verify event is logged with correct details + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [ ] 8.3 Test logDeliveryAttempt() + - Verify event is logged before delivery + - _Requirements: 3.1, 3.2_ + + - [ ] 8.4 Test logDeliverySuccess() + - Verify success event is logged + - _Requirements: 3.3_ + + - [ ] 8.5 Test logDeliveryFailure() + - Verify failure event with error details + - _Requirements: 4.1, 4.2, 4.3_ + + - [ ] 8.6 Test logAcknowledgment() + - Verify acknowledgment event is logged + - _Requirements: 5.1, 5.2_ + +- [ ] 9. Create query tests + - [ ] 9.1 Create audit-query.test.ts + - Test query interface + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 9.2 Test query by notificationId + - Verify correct logs returned + - _Requirements: 6.2_ + + - [ ] 9.3 Test query by recipient + - Verify correct logs returned + - _Requirements: 6.2_ + + - [ ] 9.4 Test query by eventType + - Test single and multiple types + - _Requirements: 6.2_ + + - [ ] 9.5 Test query by dateRange + - Verify only logs in range returned + - _Requirements: 6.2_ + + - [ ] 9.6 Test pagination + - Verify limit and offset work correctly + - Verify hasMore flag accurate + - _Requirements: 6.4_ + + - [ ] 9.7 Test query performance + - Verify sub-second response times + - _Requirements: 6.5_ + +- [ ] 10. Create integration tests + - [ ] 10.1 Create audit-integration.test.ts + - End-to-end test of notification lifecycle logging + - _Requirements: All_ + + - [ ] 10.2 Test complete notification lifecycle + - Create → Deliver → Acknowledge + - Verify all events logged in order + - _Requirements: 2.1, 3.1, 4.1, 5.1_ + + - [ ] 10.3 Test failure and retry scenario + - Delivery attempt → Failure → Retry → Success + - Verify all events logged with correct context + - _Requirements: 3.1, 4.1, 4.3_ + + - [ ] 10.4 Test immutability + - Attempt to modify audit log entry + - Verify error is returned + - _Requirements: 7.1_ + + - [ ] 10.5 Test retention policy + - Create old entries + - Verify they are archived/removed per policy + - _Requirements: 7.2, 7.3, 7.4_ + +- [ ] 11. Create documentation + - [ ] 11.1 Document audit log schema + - List all fields and types + - Explain each event type + - _Requirements: 1.1, 1.2_ + + - [ ] 11.2 Document query API + - Provide examples for each query type + - Document filter syntax + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 11.3 Document retention policy + - Explain default and custom retention + - Document archival process + - _Requirements: 7.2, 7.4_ + + - [ ] 11.4 Document compliance features + - Explain immutability guarantees + - Document audit trail for admin access + - _Requirements: 7.1, 7.5_ + +- [ ] 12. Final testing checkpoint + - [ ] 12.1 Run all tests + - Ensure no regressions + - Verify all requirements met + - _Requirements: All_ + + - [ ] 12.2 Performance testing + - Measure query times + - Verify performance meets requirements + - _Requirements: 6.5_ + + - [ ] 12.3 Compliance verification + - Verify immutability enforcement + - Test retention policy + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_ + +## Notes + +- Audit logs should be append-only to ensure immutability +- All lifecycle events should be logged regardless of success/failure +- Query performance is critical - proper indexing is essential +- Retention policy must be configurable and enforced +- Consider separate storage/archival for old logs +- Compliance and regulatory requirements may require additional fields \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/.config.kiro b/.kiro/specs/notification-expiration/.config.kiro new file mode 100644 index 0000000..ecd5bd5 --- /dev/null +++ b/.kiro/specs/notification-expiration/.config.kiro @@ -0,0 +1 @@ +{"specId": "notification-expiration", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/design.md b/.kiro/specs/notification-expiration/design.md new file mode 100644 index 0000000..29b8a48 --- /dev/null +++ b/.kiro/specs/notification-expiration/design.md @@ -0,0 +1,77 @@ +# Design Document + +## Overview + +This design implements notification expiration for the Notify-Chain listener. Notifications will store an expiration timestamp and the processing pipeline will check and skip expired notifications. + +## Architecture + +### Components + +1. **NotificationExpirationService** - Core service for checking expiration +2. **ExpirationConfig** - Configuration for expiration settings +3. **Event Registry Update** - Store expiration with events + +### Data Model + +``` +NotificationExpiration { + createdAt: number (timestamp) + expiresAt: number (timestamp) +} + +EventStore extends with expiration { + ...existing fields + expiresAt?: number (optional - if not set, uses default) +} +``` + +## Implementation Details + +### 1. Expiration Service + +```typescript +interface ExpirationConfig { + defaultExpirationMs: number; // Default 24 hours + perEventTypeExpiration: Record; + enabled: boolean; // If false, no expiration checks +} + +class NotificationExpirationService { + constructor(config: ExpirationConfig) + + isExpired(event: EventResponse): boolean + shouldProcess(event: EventResponse): boolean + getExpirationTime(eventType?: string): number +} +``` + +### 2. Integration Points + +- **EventSubscriber.shouldProcessEvent()** - Add expiration check +- **DiscordNotificationService** - Check expiration before sending +- **Config** - Add expiration configuration options + +### 3. Configuration + +```typescript +interface Config { + // ... existing fields + expiration?: { + defaultExpirationMs: number; + perEventTypeExpiration: Record; + enabled: boolean; + }; +} +``` + +## Default Values + +- `defaultExpirationMs`: 24 * 60 * 60 * 1000 (24 hours) +- `enabled`: true + +## Testing Strategy + +1. Unit tests for NotificationExpirationService +2. Integration tests for expiration in EventSubscriber +3. Edge cases: null expiration, very long expiration, past expiration \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/requirements.md b/.kiro/specs/notification-expiration/requirements.md new file mode 100644 index 0000000..57a2a00 --- /dev/null +++ b/.kiro/specs/notification-expiration/requirements.md @@ -0,0 +1,56 @@ +# Requirements Document + +## Introduction + +This feature implements expiration support to prevent outdated notifications from being processed or delivered. It ensures notifications have a valid time window and are filtered out once expired. + +## Glossary + +- **Notification**: A message sent to users about blockchain events +- **Expiration Timestamp**: The time after which a notification is no longer valid +- **Processed Notification**: A notification that has been handled by the notification service +- **Event Timestamp**: The time when the blockchain event occurred + +## Requirements + +### Requirement 1: Expiration Timestamp Storage + +**User Story:** As a system administrator, I want notifications to store an expiration timestamp, so that I can control how long notifications remain valid. + +#### Acceptance Criteria + +1. WHEN a notification is created, THE system SHALL store an expiration timestamp +2. THE expiration timestamp SHALL be configurable per notification type +3. DEFAULT expiration time SHALL be 24 hours from notification creation if not specified + +### Requirement 2: Expiration Validation + +**User Story:** As a system administrator, I want expired notifications to be blocked from processing, so that outdated notifications don't reach users. + +#### Acceptance Criteria + +1. WHEN a notification is about to be processed, THE system SHALL check if the current time exceeds the expiration timestamp +2. IF the notification is expired, THE system SHALL skip processing and log the expiration +3. IF the notification is expired, THE system SHALL NOT send the notification to any channel (Discord, etc.) +4. EXPIRED notifications SHALL be recorded in the audit log with "EXPIRED" status + +### Requirement 3: Unit Test Coverage + +**User Story:** As a developer, I want expiration checks to be covered by unit tests, so that the expiration logic works correctly. + +#### Acceptance Criteria + +1. UNIT tests SHALL verify that expired notifications are not processed +2. UNIT tests SHALL verify that valid notifications are processed +3. UNIT tests SHALL verify the default expiration time behavior +4. UNIT tests SHALL cover edge cases (null expiration, very long expiration, etc.) + +### Requirement 4: Configuration Options + +**User Story:** As a system administrator, I want to configure expiration settings, so that different notification types can have different validity periods. + +#### Acceptance Criteria + +1. THE system SHALL allow configuring default expiration time via configuration +2. THE system SHALL allow setting per-event-type expiration times +3. THE configuration SHALL support disabling expiration (infinite validity) \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/tasks.md b/.kiro/specs/notification-expiration/tasks.md new file mode 100644 index 0000000..4cb2174 --- /dev/null +++ b/.kiro/specs/notification-expiration/tasks.md @@ -0,0 +1,47 @@ +# Implementation Plan: notification-expiration + +## Overview + +This implementation plan adds expiration support to prevent outdated notifications from being processed or delivered. + +## Tasks + +- [-] 1. Add expiration configuration to types + - [x] 1.1 Add ExpirationConfig interface to Config type + - [x] 1.2 Add expiresAt field to AppCleanupConfig if applicable + - _Requirements: 1.1, 4.1, 4.2_ + +- [-] 2. Create NotificationExpirationService + - [x] 2.1 Create src/services/notification-expiration.ts + - [x] 2.2 Implement isExpired() method + - [x] 2.3 Implement shouldProcess() method + - [x] 2.4 Implement getExpirationTime() method + - _Requirements: 2.1, 2.2, 2.3_ + +- [x] 3. Update EventSubscriber to check expiration + - [x] 3.1 Integrate NotificationExpirationService in EventSubscriber + - [x] 3.2 Add expiration check in shouldProcessEvent() + - [x] 3.3 Log when notifications are skipped due to expiration + - _Requirements: 2.1, 2.2, 2.3_ + +- [x] 4. Add unit tests + - [x] 4.1 Create notification-expiration.test.ts + - [x] 4.2 Test isExpired() with past time + - [x] 4.3 Test isExpired() with future time + - [x] 4.4 Test shouldProcess() returns false for expired + - [x] 4.5 Test shouldProcess() returns true for valid + - [x] 4.6 Test default expiration behavior + - [x] 4.7 Test per-event-type expiration + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [x] 5. Update config schema if needed + - [x] 5.1 Add expiration to Config interface + - [x] 5.2 Update .env.example with expiration settings + - _Requirements: 4.1, 4.2, 4.3_ + +## Notes + +- Default expiration: 24 hours (86400000 ms) +- Check expiration after event validation but before notification sending +- Log skipped notifications with "expired" reason +- Support disabling expiration via config for backward compatibility \ No newline at end of file diff --git a/listener/.env.example b/listener/.env.example index 036b9c1..1f6a764 100644 --- a/listener/.env.example +++ b/listener/.env.example @@ -218,6 +218,19 @@ RATE_LIMIT_MAX_REQUESTS=60 # Example: {"dashboard-dev":{"maxRequests":120,"windowMs":60000}} RATE_LIMIT_CLIENT_OVERRIDES={} +# Notification Archive Configuration +# ARCHIVE_ENABLED=true # Set to 'false' to disable the background archiver +# ARCHIVE_INTERVAL_MS=21600000 # How often to run the archive cycle (default: 6 h) +# ARCHIVE_AFTER_MS=604800000 # Archive notifications completed > X ms ago (default: 7 days) +# ARCHIVE_DELETE_AFTER_MS=7776000000 # Permanently delete archive rows > X ms old (default: 90 days; 0 = never) +# ARCHIVE_BATCH_SIZE=500 # Max rows processed per cycle + +# Notification Expiration Configuration +EXPIRATION_ENABLED=true +EXPIRATION_DEFAULT_MS=86400000 +# Per-event-type expiration times in milliseconds (JSON object) +# Example: {"notification_scheduled":3600000,"alert":604800000} +# EXPIRATION_PER_EVENT_TYPE={} # ----------------------------------------------------------------------------- # Cleanup / Retention Policies # ----------------------------------------------------------------------------- diff --git a/listener/package-lock.json b/listener/package-lock.json index 1bdc842..4edd1a6 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -1575,6 +1575,56 @@ "node": ">=10" } }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", @@ -1977,6 +2027,10 @@ "node": ">=14.0.0" } }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz", + "integrity": "sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==", "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -2005,6 +2059,10 @@ "win32" ] }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz", + "integrity": "sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==", "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", @@ -2814,6 +2872,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "version": "2.11.3", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.3.tgz", "integrity": "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew==", @@ -4191,6 +4252,185 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -4258,6 +4498,11 @@ "node": ">=18" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "devOptional": true, "node_modules/color-string/node_modules/color-name": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", diff --git a/listener/src/config.test.ts b/listener/src/config.test.ts index ccbce80..8ebb90a 100644 --- a/listener/src/config.test.ts +++ b/listener/src/config.test.ts @@ -119,6 +119,83 @@ describe('Config validation', () => { }); }); + describe('EXPIRATION_CONFIG', () => { + it('loads default expiration settings when not specified', () => { + delete process.env.EXPIRATION_ENABLED; + delete process.env.EXPIRATION_DEFAULT_MS; + delete process.env.EXPIRATION_PER_EVENT_TYPE; + + const config = loadConfig(); + + expect(config.expiration).toMatchObject({ + enabled: true, + defaultExpirationMs: 86400000, // 24 hours + perEventTypeExpiration: undefined, + }); + }); + + it('loads custom default expiration time', () => { + process.env.EXPIRATION_DEFAULT_MS = '3600000'; // 1 hour + delete process.env.EXPIRATION_PER_EVENT_TYPE; + + const config = loadConfig(); + + expect(config.expiration).toMatchObject({ + enabled: true, + defaultExpirationMs: 3600000, + }); + }); + + it('loads per-event-type expiration settings', () => { + process.env.EXPIRATION_PER_EVENT_TYPE = JSON.stringify({ + notification_scheduled: 3600000, + alert: 604800000, + }); + + const config = loadConfig(); + + expect(config.expiration?.perEventTypeExpiration).toEqual({ + notification_scheduled: 3600000, + alert: 604800000, + }); + }); + + it('disables expiration when EXPIRATION_ENABLED is false', () => { + process.env.EXPIRATION_ENABLED = 'false'; + + const config = loadConfig(); + + expect(config.expiration?.enabled).toBe(false); + }); + + it('throws ConfigError for invalid EXPIRATION_DEFAULT_MS', () => { + process.env.EXPIRATION_DEFAULT_MS = 'not-a-number'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'EXPIRATION_DEFAULT_MS must be a valid integer, got "not-a-number"' + ); + }); + + it('throws ConfigError for invalid EXPIRATION_PER_EVENT_TYPE JSON', () => { + process.env.EXPIRATION_PER_EVENT_TYPE = 'not-json'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'EXPIRATION_PER_EVENT_TYPE must be valid JSON. Received: not-json' + ); + }); + + it('throws ConfigError when EXPIRATION_PER_EVENT_TYPE is not an object', () => { + process.env.EXPIRATION_PER_EVENT_TYPE = '["array", "value"]'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'EXPIRATION_PER_EVENT_TYPE must be a valid JSON object' + ); + }); + }); + describe('WEBHOOK_SECRETS', () => { it('defaults to an empty array when not set', () => { delete process.env.WEBHOOK_SECRETS; diff --git a/listener/src/config.ts b/listener/src/config.ts index ba6fa34..2d4e9e6 100644 --- a/listener/src/config.ts +++ b/listener/src/config.ts @@ -1,3 +1,4 @@ +import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig, ExpirationConfig, ApiKey } from './types'; import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig } from './types'; import { Config, ContractConfig, DiscordConfig, WebhookSecret, ApiKey, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig } from './types'; @@ -190,6 +191,29 @@ function loadRetrySchedulerConfig(): RetrySchedulerOptions { }; } +function loadExpirationConfig(): ExpirationConfig { + const defaultExpirationMs = parseIntegerEnv('EXPIRATION_DEFAULT_MS', String(24 * 60 * 60 * 1000)); + const perEventTypeExpirationJson = trimEnv('EXPIRATION_PER_EVENT_TYPE'); + let perEventTypeExpiration: Record | undefined; + + if (perEventTypeExpirationJson) { + try { + perEventTypeExpiration = JSON.parse(perEventTypeExpirationJson); + if (typeof perEventTypeExpiration !== 'object' || perEventTypeExpiration === null) { + throw new ConfigError('EXPIRATION_PER_EVENT_TYPE must be a valid JSON object'); + } + } catch (e) { + throw new ConfigError(`EXPIRATION_PER_EVENT_TYPE must be valid JSON. Received: ${perEventTypeExpirationJson}`); + } + } + + return { + defaultExpirationMs, + perEventTypeExpiration, + enabled: trimEnv('EXPIRATION_ENABLED') !== 'false', + }; +} + export function loadConfig(): Config { validateRequiredEnvVars(); @@ -248,6 +272,7 @@ export function loadConfig(): Config { }, cleanup: loadCleanupConfig(), analytics: loadAnalyticsConfig(), + expiration: loadExpirationConfig(), }; } diff --git a/listener/src/services/event-subscriber.test.ts b/listener/src/services/event-subscriber.test.ts index c5028ab..2fd5bf2 100644 --- a/listener/src/services/event-subscriber.test.ts +++ b/listener/src/services/event-subscriber.test.ts @@ -636,3 +636,265 @@ describe('EventSubscriber', () => { }); }); }); + + describe('notification expiration (Task 3: Requirements 2.1, 2.2, 2.3)', () => { + const DEFAULT_EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours + const NOW = Date.now(); + + it('skips expired events when expiration service is configured', async () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); // 1 second past expiration + const expiredEvent = createMockEvent({ + id: 'expired-event', + receivedAt: expiredTime, + }); + + mockGetEvents.mockResolvedValue({ + events: [expiredEvent], + cursor: 'cursor-expired', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be skipped due to expiration + expect(countLogCalls('info', 'Processing event')).toBe(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + eventId: 'expired-event', + reason: 'expired', + }) + ); + }); + + it('processes valid (non-expired) events when expiration service is configured', async () => { + const recentEvent = createMockEvent({ + id: 'recent-event', + receivedAt: NOW, + }); + + mockGetEvents.mockResolvedValue({ + events: [recentEvent], + cursor: 'cursor-recent', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be processed + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('logs expiration with timestamp details', async () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const expiredEvent = createMockEvent({ + id: 'expired-details', + receivedAt: expiredTime, + }); + + mockGetEvents.mockResolvedValue({ + events: [expiredEvent], + cursor: 'cursor-expired-details', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + contractAddress: contractConfig.address, + eventId: 'expired-details', + eventName: 'TaskCreated', + receivedAt: expiredTime, + currentTime: expect.any(Number), + reason: 'expired', + }) + ); + }); + + it('processes events when expiration is disabled', async () => { + const veryOldTime = NOW - (365 * 24 * 60 * 60 * 1000); // 1 year ago + const oldEvent = createMockEvent({ + id: 'very-old-event', + receivedAt: veryOldTime, + }); + + mockGetEvents.mockResolvedValue({ + events: [oldEvent], + cursor: 'cursor-old', + }); + + const configWithDisabledExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }, + }; + + const subscriber = new EventSubscriber(configWithDisabledExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be processed even though it's very old + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('processes all events when no expiration config is provided', async () => { + const oldEvent = createMockEvent({ + id: 'no-expiration-config', + receivedAt: NOW - (365 * 24 * 60 * 60 * 1000), + }); + + mockGetEvents.mockResolvedValue({ + events: [oldEvent], + cursor: 'cursor-no-expiration', + }); + + // Config without expiration settings + const configWithoutExpiration: Config = { + ...testConfig, + }; + + const subscriber = new EventSubscriber(configWithoutExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be processed - no expiration service initialized + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('handles mixed batch with both expired and valid events', async () => { + const expiredEvent = createMockEvent({ + id: 'expired-in-batch', + receivedAt: NOW - (DEFAULT_EXPIRATION_MS + 1000), + }); + const validEvent = createMockEvent({ + id: 'valid-in-batch', + receivedAt: NOW, + }); + + mockGetEvents.mockResolvedValue({ + events: [expiredEvent, validEvent], + cursor: 'cursor-mixed-batch', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + // Only the valid event should be processed + expect(countLogCalls('info', 'Processing event')).toBe(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + eventId: 'expired-in-batch', + reason: 'expired', + }) + ); + }); + + it('respects per-event-type expiration settings', async () => { + const fastEventExpiredTime = NOW - (5 * 60 * 1000 + 1000); // 5 minutes + 1 second + const slowEventExpiredTime = NOW - (7 * 24 * 60 * 60 * 1000 + 1000); // 7 days + 1 second + + const fastEvent = createMockEvent({ + id: 'fast-expired', + receivedAt: fastEventExpiredTime, + }); + const slowEvent = createMockEvent({ + id: 'slow-expired', + receivedAt: slowEventExpiredTime, + }); + + // First call returns fast event, second returns slow event + mockGetEvents + .mockResolvedValueOnce({ + events: [fastEvent], + cursor: 'cursor-fast', + }) + .mockResolvedValueOnce({ + events: [slowEvent], + cursor: 'cursor-slow', + }); + + const configWithPerTypeExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + TaskCreated: 5 * 60 * 1000, // 5 minutes for TaskCreated + }, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithPerTypeExpiration); + + // First check - fast event should be expired + await (subscriber as any).checkForEvents(); + expect(countLogCalls('info', 'Processing event')).toBe(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + eventId: 'fast-expired', + reason: 'expired', + }) + ); + + // Reset mock counters + jest.clearAllMocks(); + + // Second check - slow event should NOT be expired (uses default 24h) + await (subscriber as any).checkForEvents(); + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('initializes expirationService only when config.expiration is provided', () => { + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + const subscriber1 = new EventSubscriber(configWithExpiration); + expect((subscriber1 as any).expirationService).toBeDefined(); + expect((subscriber1 as any).expirationService).not.toBeNull(); + + const configWithoutExpiration: Config = { ...testConfig }; + const subscriber2 = new EventSubscriber(configWithoutExpiration); + expect((subscriber2 as any).expirationService).toBeNull(); + }); + }); +}); diff --git a/listener/src/services/event-subscriber.ts b/listener/src/services/event-subscriber.ts index c4d98d9..8c3d9f2 100644 --- a/listener/src/services/event-subscriber.ts +++ b/listener/src/services/event-subscriber.ts @@ -13,6 +13,7 @@ import { DiscordNotificationService } from './discord-notification'; import { NotificationRetryQueue } from './notification-retry-queue'; import { EventDeduplicationService } from './event-deduplication-service'; import { EventProcessingQueue } from './event-processing-queue'; +import { NotificationExpirationService } from './notification-expiration'; export class EventSubscriber { private config: Config; @@ -24,11 +25,18 @@ export class EventSubscriber { private retryQueue: NotificationRetryQueue | null = null; private deduplicationService: EventDeduplicationService | null = null; private eventQueue: EventProcessingQueue | null = null; + private expirationService: NotificationExpirationService | null = null; constructor(config: Config, deduplicationService?: EventDeduplicationService) { this.config = config; this.server = new StellarSDK.rpc.Server(config.stellarRpcUrl); this.deduplicationService = deduplicationService ?? null; + + // Initialize expiration service if configured + if (config.expiration) { + this.expirationService = new NotificationExpirationService(config.expiration); + } + if (config.discord) { this.discordService = new DiscordNotificationService(config.discord); this.retryQueue = new NotificationRetryQueue( @@ -175,6 +183,21 @@ export class EventSubscriber { contractConfig: ContractConfig, requestId: string = '' ): boolean { + // Check if event has expired + if (this.expirationService && !this.expirationService.shouldProcess(event)) { + const eventName = getEventName(event.topic); + logger.warn('Skipping expired notification', { + requestId, + contractAddress: contractConfig.address, + eventId: event.id, + eventName, + receivedAt: event.receivedAt, + currentTime: Date.now(), + reason: 'expired', + }); + return false; + } + const validation = validateEventPayload(event); if (!validation.valid) { logger.warn('Skipping invalid event payload', { diff --git a/listener/src/services/notification-expiration.test.ts b/listener/src/services/notification-expiration.test.ts new file mode 100644 index 0000000..9a7a219 --- /dev/null +++ b/listener/src/services/notification-expiration.test.ts @@ -0,0 +1,569 @@ +import * as StellarSDK from '@stellar/stellar-sdk'; +import { NotificationExpirationService } from './notification-expiration'; +import { ExpirationConfig } from '../types'; +import logger from '../utils/logger'; + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +const mockLogger = logger as jest.Mocked; + +describe('NotificationExpirationService', () => { + const DEFAULT_EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours + const NOW = Date.now(); + + let service: NotificationExpirationService; + let config: ExpirationConfig; + + beforeEach(() => { + jest.clearAllMocks(); + config = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }; + service = new NotificationExpirationService(config); + }); + + describe('isExpired()', () => { + it('should return false for recently received events', () => { + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-1', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW, + }; + + const result = service.isExpired(event); + expect(result).toBe(false); + }); + + it('should return true for expired events', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); // 1 second past expiration + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-expired', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = service.isExpired(event); + expect(result).toBe(true); + }); + + it('should return false for events at exact expiration boundary', () => { + const boundaryTime = NOW - DEFAULT_EXPIRATION_MS; + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-boundary', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: boundaryTime, + }; + + const result = service.isExpired(event); + // At exact boundary, should not be expired (> not >=) + expect(result).toBe(false); + }); + + it('should return false when expiration is disabled', () => { + const disabledConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }; + const disabledService = new NotificationExpirationService(disabledConfig); + + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-old', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = disabledService.isExpired(event); + expect(result).toBe(false); + }); + }); + + describe('shouldProcess()', () => { + it('should return true for valid (non-expired) events', () => { + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-valid', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW, + }; + + const result = service.shouldProcess(event); + expect(result).toBe(true); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should return false for expired events', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-old', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = service.shouldProcess(event); + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Event skipped due to expiration', + expect.objectContaining({ + eventId: 'event-old', + }) + ); + }); + + it('should return true when expiration is disabled', () => { + const disabledConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }; + const disabledService = new NotificationExpirationService(disabledConfig); + + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-old', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = disabledService.shouldProcess(event); + expect(result).toBe(true); + }); + + it('should include eventType in log when provided', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-typed', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + service.shouldProcess(event, 'notification_scheduled'); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Event skipped due to expiration', + expect.objectContaining({ + eventType: 'notification_scheduled', + }) + ); + }); + }); + + describe('getExpirationTime()', () => { + it('should return default expiration time when no event type provided', () => { + const result = service.getExpirationTime(); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should return default expiration when event type has no override', () => { + const result = service.getExpirationTime('unknown_event'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should return per-event-type expiration when available', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + 'notification_scheduled': 60 * 60 * 1000, // 1 hour + 'notification_revoked': 5 * 60 * 1000, // 5 minutes + }, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + expect(serviceWithPerType.getExpirationTime('notification_scheduled')).toBe( + 60 * 60 * 1000 + ); + expect(serviceWithPerType.getExpirationTime('notification_revoked')).toBe( + 5 * 60 * 1000 + ); + }); + + it('should fall back to default when event type not in per-type map', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + 'notification_scheduled': 60 * 60 * 1000, + }, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + const result = serviceWithPerType.getExpirationTime('unknown_type'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should handle empty per-event-type map', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: {}, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + const result = serviceWithPerType.getExpirationTime('any_type'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should handle undefined per-event-type map', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + const result = serviceWithPerType.getExpirationTime('any_type'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + }); + + describe('Configuration management', () => { + it('should get current config', () => { + const result = service.getConfig(); + expect(result).toEqual(config); + expect(result.defaultExpirationMs).toBe(DEFAULT_EXPIRATION_MS); + expect(result.enabled).toBe(true); + }); + + it('should update config at runtime', () => { + const newConfig: ExpirationConfig = { + defaultExpirationMs: 60 * 60 * 1000, // 1 hour + enabled: false, + }; + + service.setConfig(newConfig); + const result = service.getConfig(); + expect(result).toEqual(newConfig); + expect(result.defaultExpirationMs).toBe(60 * 60 * 1000); + expect(result.enabled).toBe(false); + }); + + it('should apply new config to expiration checks', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-reconfig', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + // Should be expired with initial config + expect(service.shouldProcess(event)).toBe(false); + + // Disable expiration + service.setConfig({ + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }); + + // Should now process + expect(service.shouldProcess(event)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle very long expiration times', () => { + const veryLongConfig: ExpirationConfig = { + defaultExpirationMs: 365 * 24 * 60 * 60 * 1000, // 1 year + enabled: true, + }; + const longService = new NotificationExpirationService(veryLongConfig); + + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-long', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW - (24 * 60 * 60 * 1000), // 1 day old + }; + + const result = longService.shouldProcess(event); + expect(result).toBe(true); + }); + + it('should handle very short expiration times', () => { + const shortConfig: ExpirationConfig = { + defaultExpirationMs: 1000, // 1 second + enabled: true, + }; + const shortService = new NotificationExpirationService(shortConfig); + + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-short', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW - 2000, // 2 seconds old + }; + + const result = shortService.shouldProcess(event); + expect(result).toBe(false); + }); + + it('should handle zero expiration time (instant expiration)', () => { + const zeroConfig: ExpirationConfig = { + defaultExpirationMs: 0, + enabled: true, + }; + const zeroService = new NotificationExpirationService(zeroConfig); + + const recentEvent: StellarSDK.rpc.Api.EventResponse = { + id: 'event-zero', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW, + }; + + // Even recent events should be expired with zero expiration + const result = zeroService.shouldProcess(recentEvent); + expect(result).toBe(false); + }); + }); + + describe('default expiration behavior (Requirement 2.1)', () => { + it('should use default 24-hour expiration when no per-type config', () => { + const defaultConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }; + const defaultService = new NotificationExpirationService(defaultConfig); + + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-default', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + expect(defaultService.shouldProcess(event)).toBe(false); + }); + }); + + describe('per-event-type expiration (Requirement 2.3)', () => { + it('should apply per-event-type expiration correctly', () => { + const perTypeConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + 'fast_event': 5 * 60 * 1000, // 5 minutes + 'slow_event': 7 * 24 * 60 * 60 * 1000, // 7 days + }, + enabled: true, + }; + const perTypeService = new NotificationExpirationService(perTypeConfig); + + const eventTime = NOW - (10 * 60 * 1000); // 10 minutes ago + + // Fast event should be expired (only 5 min TTL) + const fastEvent: StellarSDK.rpc.Api.EventResponse = { + id: 'fast-event', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: eventTime, + }; + + // Slow event should not be expired (7 day TTL) + const slowEvent: StellarSDK.rpc.Api.EventResponse = { + id: 'slow-event', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: eventTime, + }; + + expect(perTypeService.shouldProcess(fastEvent, 'fast_event')).toBe(false); + expect(perTypeService.shouldProcess(slowEvent, 'slow_event')).toBe(true); + }); + }); + + describe('disabling expiration (Requirement 4.2)', () => { + it('should allow disabling expiration via configuration', () => { + const disabledConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }; + const disabledService = new NotificationExpirationService(disabledConfig); + + const veryOldTime = NOW - (365 * 24 * 60 * 60 * 1000); // 1 year ago + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'ancient-event', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: veryOldTime, + }; + + // Should process even though event is extremely old + const result = disabledService.shouldProcess(event); + expect(result).toBe(true); + }); + }); +}); diff --git a/listener/src/services/notification-expiration.ts b/listener/src/services/notification-expiration.ts new file mode 100644 index 0000000..e2fdc50 --- /dev/null +++ b/listener/src/services/notification-expiration.ts @@ -0,0 +1,116 @@ +import * as StellarSDK from '@stellar/stellar-sdk'; +import { ExpirationConfig } from '../types'; +import logger from '../utils/logger'; + +/** + * NotificationExpirationService handles expiration checks for notifications. + * + * This service: + * - Checks if notifications have exceeded their expiration timestamp + * - Supports configurable default expiration (24 hours by default) + * - Supports per-event-type expiration overrides + * - Can be disabled via configuration for backward compatibility + */ +export class NotificationExpirationService { + private config: ExpirationConfig; + + constructor(config: ExpirationConfig) { + this.config = config; + } + + /** + * Check if a notification has expired based on its receivedAt timestamp + * and the appropriate expiration time. + * + * @param event - The blockchain event to check + * @returns true if the event has expired, false otherwise + */ + isExpired(event: StellarSDK.rpc.Api.EventResponse): boolean { + // If expiration is disabled, nothing is ever expired + if (!this.config.enabled) { + return false; + } + + // Get the expiration time in milliseconds for this event + const expirationTimeMs = this.getExpirationTime(); + + // Calculate when this event should expire + // receivedAt is in milliseconds (Unix timestamp) + const expiresAtMs = event.receivedAt + expirationTimeMs; + + // Check if current time exceeds the expiration time + const currentTimeMs = Date.now(); + return currentTimeMs > expiresAtMs; + } + + /** + * Determine if an event should be processed based on expiration status. + * + * This is the main entry point for checking if an event is still valid. + * + * @param event - The blockchain event to check + * @param eventType - Optional event type for per-type expiration lookup + * @returns true if the event should be processed, false if expired + */ + shouldProcess( + event: StellarSDK.rpc.Api.EventResponse, + eventType?: string + ): boolean { + // If expiration is disabled, always process + if (!this.config.enabled) { + return true; + } + + const expired = this.isExpired(event); + + if (expired) { + logger.warn('Event skipped due to expiration', { + eventId: event.id, + receivedAt: event.receivedAt, + eventType, + currentTime: Date.now(), + }); + } + + return !expired; + } + + /** + * Get the expiration time in milliseconds for a given event type. + * + * Uses per-event-type configuration if available, otherwise uses default. + * + * @param eventType - Optional event type to look up specific expiration time + * @returns Expiration time in milliseconds + */ + getExpirationTime(eventType?: string): number { + // If an event type is provided, check for per-type expiration + if (eventType && this.config.perEventTypeExpiration) { + const perTypeExpiration = this.config.perEventTypeExpiration[eventType]; + if (perTypeExpiration !== undefined) { + return perTypeExpiration; + } + } + + // Return default expiration + return this.config.defaultExpirationMs; + } + + /** + * Get the current configuration. + * + * @returns The expiration configuration + */ + getConfig(): ExpirationConfig { + return this.config; + } + + /** + * Update the configuration at runtime. + * + * @param config - New expiration configuration + */ + setConfig(config: ExpirationConfig): void { + this.config = config; + } +} diff --git a/listener/src/types/index.ts b/listener/src/types/index.ts index be5eef7..5ff0c02 100644 --- a/listener/src/types/index.ts +++ b/listener/src/types/index.ts @@ -61,6 +61,7 @@ export interface Config { rateLimit?: RateLimitConfig; cleanup?: AppCleanupConfig; analytics?: AnalyticsConfig; + expiration?: ExpirationConfig; } export interface SchedulerConfig { @@ -119,3 +120,12 @@ export interface AnalyticsConfig { snapshotRetentionDays: number; } +export interface ExpirationConfig { + /** Default expiration time in milliseconds (default: 24 hours = 86400000). */ + defaultExpirationMs: number; + /** Per-event-type expiration times in milliseconds. */ + perEventTypeExpiration?: Record; + /** Whether expiration checking is enabled (default: true). */ + enabled: boolean; +} +