diff --git a/PR_267_REVIEW_SUMMARY.md b/PR_267_REVIEW_SUMMARY.md deleted file mode 100644 index ef0fa6dd..00000000 --- a/PR_267_REVIEW_SUMMARY.md +++ /dev/null @@ -1,135 +0,0 @@ -# PR #267 Review and Workflow Issues Resolution - -## Overview -PR #267 implements comprehensive logging and monitoring infrastructure for Bridge-Watch backend. The PR had merge conflicts and workflow test failures that have been resolved. - -## Issues Found and Fixed - -### 1. Merge Conflicts (RESOLVED ✅) -**Issue**: PR showed `mergeable: "CONFLICTING"` status -**Root Cause**: Upstream/main had 7 new commits that conflicted with feature branch -**Resolution**: -- Fetched latest upstream/main -- Merged upstream/main into feature branch -- Resolved conflicts in: - - `backend/src/index.ts` - Integrated logging middleware with validation middleware and websocket config - - `backend/src/api/routes/metrics.ts` - Kept upstream's comprehensive metrics routes implementation -- Pushed resolved branch -- PR now shows `mergeable: "MERGEABLE"` ✅ - -### 2. Function Name Mismatch (RESOLVED ✅) -**Issue**: `TypeError: registerMetricsEndpoint is not a function` -**Root Cause**: metrics.ts exports `metricsRoutes` but index.ts was calling `registerMetricsEndpoint` -**Resolution**: -- Updated import in index.ts: `import { metricsRoutes } from "./api/routes/metrics.js"` -- Updated function call: `await metricsRoutes(server as any)` -- Commit: `fix: correct metrics endpoint import and function call` - -### 3. Duplicate Route Registration (RESOLVED ✅) -**Issue**: `FastifyError: Method 'GET' already declared for route '/health'` -**Root Cause**: -- `registerRoutes()` already registers health routes via `healthRoutes` with prefix `/health` -- `registerHealthCheckRoutes()` was being called separately, trying to register `/health` again -**Resolution**: -- Removed duplicate call to `registerHealthCheckRoutes()` -- Removed unused import -- Health routes are now registered only once through the main routes registration -- Commit: `fix: remove duplicate health check route registration` - -## Workflow Status - -### Current Test Results -- ✅ ESLint Analysis: SUCCESS -- ✅ Dependency Review: SUCCESS -- ⏳ Rust Clippy Analysis: IN_PROGRESS -- ⏳ Unit Tests: Running (after fixes) -- ⏳ Integration Tests: Running (after fixes) -- ⏳ k6 Load Test: Running (after fixes) - -### Implementation Summary -**Completed Tasks**: 58/70 core tasks (83%) - -**Implemented Components**: -1. ✅ Core Logger Enhancement (Pino with JSON formatting) -2. ✅ Correlation ID and Tracing (TraceManager with multi-format support) -3. ✅ Request/Response Logging Middleware -4. ✅ Metrics Collection (HTTP, database, queue, custom metrics) -5. ✅ Health Check Service (/health, /ready, /live endpoints) -6. ✅ Prometheus Metrics Endpoint (/metrics) -7. ✅ Error Handling and Edge Cases -8. ✅ Configuration and Environment Setup -9. ✅ Integration and Middleware Registration - -**Pending Phases**: -- Phase 6: Sensitive Data Redaction (0/8 tasks) -- Phase 7: Performance Monitoring (0/8 tasks) -- Phase 9: Log Aggregation Compatibility (0/6 tasks) -- Phase 10: Audit Logging (0/8 tasks) -- Phase 14: Documentation and Deployment (0/8 tasks) -- Phase 15: Final Integration and Validation (0/10 tasks) - -## Files Modified in PR - -### Core Implementation Files -- `backend/src/api/middleware/correlation.middleware.ts` - TraceManager with UUID generation -- `backend/src/api/middleware/logging.middleware.ts` - Request/response logging with redaction -- `backend/src/utils/metrics.ts` - MetricsCollector service -- `backend/src/services/health-check.service.ts` - Health check endpoints -- `backend/src/api/routes/metrics.ts` - Prometheus metrics endpoint - -### Integration Files -- `backend/src/index.ts` - Middleware registration and server setup - -## Commits in Feature Branch -1. feat: build logging and monitoring infrastructure -2. fix: integrate logging and monitoring middleware into server -3. fix: improve logging middleware and add memory leak prevention -4. fix: resolve async import issue in health check service -5. docs: add logging and monitoring infrastructure review document -6. Merge upstream/main: resolve conflicts in index.ts and metrics.ts -7. fix: correct metrics endpoint import and function call -8. fix: remove duplicate health check route registration - -## Next Steps - -1. **Wait for CI/CD to complete** - All workflow checks should pass with the latest fixes -2. **Code Review** - Request review from maintainers -3. **Merge** - Once approved, PR can be merged to main -4. **Post-Merge Tasks**: - - Implement remaining optional test tasks (Phase 11) - - Implement Phase 6-10 features (redaction, performance, audit logging, etc.) - - Complete documentation and deployment guides (Phase 14) - - Final integration and validation (Phase 15) - -## Testing Recommendations - -### Manual Testing -```bash -# Test health endpoints -curl http://localhost:3000/health -curl http://localhost:3000/ready -curl http://localhost:3000/live - -# Test metrics endpoint -curl http://localhost:3000/metrics -curl http://localhost:3000/metrics/json -curl http://localhost:3000/metrics/health - -# Test correlation ID propagation -curl -H "x-correlation-id: test-123" http://localhost:3000/api/v1/assets -``` - -### Automated Testing -- Run integration tests: `npm run test:integration` -- Run unit tests: `npm run test` -- Run linting: `npm run lint` - -## Conclusion - -All workflow issues have been resolved. The PR is now ready for: -1. ✅ Merge conflict resolution - COMPLETE -2. ✅ Function naming fixes - COMPLETE -3. ✅ Route registration fixes - COMPLETE -4. ⏳ CI/CD workflow completion - IN PROGRESS - -The logging and monitoring infrastructure is fully functional and ready for production use. diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index 825bfe73..00000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,370 +0,0 @@ -# Trusted Source Registry Implementation - -## Overview - -This PR implements a trusted source registry for the Bridge Watch Soroban contract, providing an additional security layer for controlling which external addresses are authorized to submit contract data. - -## Issue - -Closes #[issue-number] - -## What Changed - -### New Features - -✅ **Register Trusted Sources** - Admin-only operation to register external sources -✅ **Revoke Sources** - Admin-only operation to revoke sources while preserving audit trail -✅ **Query Source Status** - Multiple query methods for different use cases -✅ **Submission Gating** - Gate health and price submissions by source trust -✅ **Event Emission** - Events emitted for all registration and revocation actions -✅ **Audit Trail** - Complete audit trail with timestamps and actor tracking - -### Files Added - -- `contracts/soroban/src/source_trust.rs` - Core implementation (370 lines) -- `contracts/soroban/tests/source_trust.test.rs` - Test suite (450+ lines) -- `contracts/soroban/docs/TRUSTED_SOURCE_REGISTRY.md` - User documentation (500+ lines) -- `contracts/soroban/TRUSTED_SOURCE_IMPLEMENTATION.md` - Developer documentation (400+ lines) -- `contracts/soroban/TRUSTED_SOURCE_QUICK_REFERENCE.md` - Quick reference (200+ lines) - -### Files Modified - -- `contracts/soroban/src/lib.rs` - Integration and public API - - Added module declaration - - Added storage keys - - Added 6 public contract methods - - Updated `submit_health()` to gate by source trust - - Updated `submit_price()` to gate by source trust - -## API Reference - -### Register Source - -```rust -pub fn register_trusted_source( - env: Env, - caller: Address, - source_address: Address, - name: String, -) -``` - -### Revoke Source - -```rust -pub fn revoke_trusted_source( - env: Env, - caller: Address, - source_address: Address, -) -``` - -### Query Methods - -```rust -pub fn is_trusted_source(env: Env, source_address: Address) -> bool -pub fn get_trusted_source(env: Env, source_address: Address) -> Option -pub fn get_all_trusted_sources(env: Env) -> Vec -pub fn get_active_trusted_sources(env: Env) -> Vec -``` - -## Usage Example - -```rust -// 1. Register trusted source -contract.register_trusted_source( - env, - admin_address, - oracle_address, - "CoinGecko Price Oracle".into(), -); - -// 2. Grant submission role -contract.grant_role( - env, - admin_address, - oracle_address, - AdminRole::PriceSubmitter, -); - -// 3. Source can now submit -contract.submit_price( - env, - oracle_address, - "USDC".into(), - 1_000_000, - "coingecko".into(), -); - -// 4. Monitor sources -let active = contract.get_active_trusted_sources(env); -for source in active.iter() { - log!("Active source: {}", source.name); -} - -// 5. Revoke if needed -contract.revoke_trusted_source(env, admin_address, old_oracle); -``` - -## Security Model - -### Defense in Depth - -Three layers of security: - -1. **Authentication**: Caller must authenticate via `require_auth()` -2. **Authorization**: Caller must have appropriate role (RBAC) -3. **Trust**: Caller must be a registered trusted source (when enabled) - -### Opt-In Enforcement - -Trust enforcement is **opt-in**: - -- If no trusted sources are registered, submissions work as before (role-based only) -- Once the first source is registered, trust enforcement activates -- All subsequent submissions must come from trusted sources - -This ensures backward compatibility while providing enhanced security when needed. - -### Audit Trail - -Every action records: - -- Actor address (who performed the action) -- Timestamp (when it occurred) -- Current state (active/revoked) -- Historical changes (preserved for audit) - -## Testing - -### Test Coverage - -- ✅ 20+ comprehensive test cases -- ✅ Unit tests for all operations -- ✅ Integration tests with submission gating -- ✅ Edge case coverage -- ✅ Audit trail verification -- ✅ All tests passing - -### Run Tests - -```bash -# Run all tests -cargo test --package bridge-watch-soroban - -# Run only source trust tests -cargo test --package bridge-watch-soroban --test source_trust -``` - -## Compilation Status - -✅ **Code compiles successfully** with no errors - -``` -Checking bridge-watch-contracts v0.1.0 -warning: unused variable: `stddev` (pre-existing) -warning: unused variable: `min_price` (pre-existing) -warning: unused variable: `max_price` (pre-existing) -``` - -Only minor warnings about unused variables that were pre-existing in the codebase. - -## Documentation - -### User Documentation - -- **`docs/TRUSTED_SOURCE_REGISTRY.md`**: Complete user guide - - API reference with examples - - Trust model explanation - - Usage patterns - - Security considerations - - Migration guide - -### Developer Documentation - -- **`TRUSTED_SOURCE_IMPLEMENTATION.md`**: Implementation details - - Architecture overview - - Data structures - - Storage layout - - Testing guide - -### Quick Reference - -- **`TRUSTED_SOURCE_QUICK_REFERENCE.md`**: Quick reference guide - - Common patterns - - API summary - - Error messages - - Best practices - -## Backward Compatibility - -✅ **Fully backward compatible** - -- Feature is opt-in -- No breaking changes to existing functionality -- Existing deployments continue to work without modification -- Trust enforcement only activates when sources are registered - -## Migration Path - -### For Existing Deployments - -1. Deploy updated contract -2. No immediate changes required -3. Register sources gradually as needed -4. Monitor submissions -5. Revoke old sources when rotating - -### For New Deployments - -1. Initialize contract -2. Register all trusted sources upfront -3. Grant roles to sources -4. Begin operations - -## Events - -### SourceRegisteredEvent - -```rust -pub struct SourceRegisteredEvent { - pub source_address: Address, - pub name: String, - pub registered_by: Address, - pub timestamp: u64, -} -``` - -**Topic**: `src_reg` - -### SourceRevokedEvent - -```rust -pub struct SourceRevokedEvent { - pub source_address: Address, - pub revoked_by: Address, - pub timestamp: u64, -} -``` - -**Topic**: `src_rev` - -## Checklist - -- [x] Add source registry storage -- [x] Create register function -- [x] Create revoke function -- [x] Gate submissions by source trust -- [x] Add comprehensive tests -- [x] Document trust model -- [x] Event emission -- [x] Audit trail -- [x] Admin-only writes -- [x] Query functions -- [x] Integration tests -- [x] User documentation -- [x] Code compiles successfully -- [x] All tests passing - -## Screenshots/Examples - -### Registering a Source - -```rust -contract.register_trusted_source( - env, - admin_address, - oracle_address, - "CoinGecko Price Oracle".into(), -); -``` - -**Event Emitted**: - -``` -Topic: src_reg -Data: { - source_address: oracle_address, - name: "CoinGecko Price Oracle", - registered_by: admin_address, - timestamp: 1234567890 -} -``` - -### Querying Sources - -```rust -let all_sources = contract.get_all_trusted_sources(env); -// Returns: Vec -// [ -// { -// source_address: oracle1, -// name: "CoinGecko Oracle", -// is_active: true, -// registered_at: 1234567890 -// }, -// { -// source_address: oracle2, -// name: "Chainlink Feed", -// is_active: false, -// registered_at: 1234567800 -// } -// ] -``` - -### Submission Gating - -```rust -// Before: No sources registered -contract.submit_health(caller, ...); // ✅ Works (role-based only) - -// After: First source registered -contract.register_trusted_source(env, admin, oracle, "Oracle".into()); -contract.submit_health(caller, ...); // ❌ Fails if caller not trusted -contract.submit_health(oracle, ...); // ✅ Works (oracle is trusted) -``` - -## Review Notes - -### Key Points - -1. **Opt-in design**: Trust enforcement only activates when sources are registered -2. **Backward compatible**: No breaking changes to existing functionality -3. **Complete audit trail**: All actions logged with timestamps and actors -4. **Admin-only mutations**: All registration/revocation requires admin permissions -5. **Event emission**: All actions emit events for monitoring -6. **Comprehensive testing**: 20+ test cases covering all scenarios - -### Security Considerations - -- Defense in depth with three security layers -- Complete audit trail for compliance -- Admin-only writes with ACL integration -- Event emission for monitoring -- Opt-in design for safety - -### Performance Impact - -- Minimal: Only adds a single storage lookup when sources are registered -- No impact when no sources are registered (backward compatible) -- Storage efficient: Uses persistent storage with minimal overhead - -## Future Enhancements - -Potential improvements for future versions: - -1. Source expiration (automatic revocation after time period) -2. Source quotas (rate limiting per source) -3. Source reputation (track submission quality) -4. Multi-signature registration (require multiple admins) -5. Source categories (different trust levels for different data types) - -## Related Issues - -- Closes #[issue-number] - -## Additional Context - -This implementation follows the Soroban best practices and integrates seamlessly with the existing ACL system. The opt-in design ensures that existing deployments are not affected while providing enhanced security for new deployments or when explicitly enabled. - -The complete audit trail and event emission make this feature suitable for production use in regulated environments where compliance and auditability are critical. diff --git a/PR_DESCRIPTION_168.md b/PR_DESCRIPTION_168.md deleted file mode 100644 index 87fabb2f..00000000 --- a/PR_DESCRIPTION_168.md +++ /dev/null @@ -1,20 +0,0 @@ -## Summary -- #168: Implement End-to-End Testing Suite - -## Changes -- Added Playwright E2E infrastructure at the repository root with cross-browser and mobile projects, retries for flaky test handling, and artifact reporters (HTML, JUnit, JSON). -- Implemented page-object-based tests for critical flows: landing-to-dashboard navigation, dashboard customization interactions, bridges page rendering, and mobile navigation behavior. -- Added deterministic API fixture mocking (`e2e/fixtures` + `e2e/utils/mockApi.ts`) to keep E2E coverage stable without requiring backend services. -- Added CI integration via `.github/workflows/e2e.yml` to install Playwright browsers, execute E2E tests, and upload reports/results artifacts. -- Documented E2E patterns, execution commands, flaky-test strategy, and data management conventions in `docs/E2E_TESTING_GUIDE.md`. -- Updated root scripts and ignore rules to support E2E execution and artifact management. - -## Testing -- [x] Run `npm run test:e2e` locally (all configured projects) -- [x] Verify cross-browser matrix: Chromium, Firefox, WebKit -- [x] Verify mobile viewport flow via `mobile-chrome` project -- [x] Validate dashboard onboarding does not block automated flows (localStorage pre-seeded) -- [x] Validate fixture-driven API responses for `/api/v1/assets`, `/api/v1/assets/:symbol/health`, `/api/v1/bridges` - -## Closing -Closes StellaBridge/Bridge-Watch#168 diff --git a/PR_DESCRIPTION_377.md b/PR_DESCRIPTION_377.md deleted file mode 100644 index 818b8c95..00000000 --- a/PR_DESCRIPTION_377.md +++ /dev/null @@ -1,339 +0,0 @@ -# PR #377: Build Environment Configuration Service with Full Audit Trail - -## Summary - -Implements a production-grade environment configuration service supporting per-environment key-value configuration (dev, staging, prod-us-east, prod-eu-west) with runtime validation, secret reference resolution, complete audit trail, bulk import/export, safe defaults fallback, and Admin API for management. - -**Issue:** #377 - -## Features Implemented - -### ✅ Core Features -- **Hierarchical Resolution** — Environment-specific → Global → Safe defaults -- **Full Audit Trail** — Track every change (who/when/why) in immutable log -- **Type Safety** — Zod validation for all 35 configuration keys -- **Encryption at Rest** — Sensitive values encrypted with AES-256-GCM -- **Redis Caching** — Sub-millisecond cache hits with 5min TTL -- **Cluster Coherence** — Pub/sub invalidation across all instances -- **Zero-Downtime Deployments** — Safe rollouts with cache TTL -- **Bulk Operations** — Atomic import/export for infrastructure-as-code - -### ✅ Database Schema -- `configs` table — Core configuration storage with hierarchical environment support -- `config_audits` table — Immutable append-only audit log for all changes -- Indexes for performance (environment+key, changed_at) -- Foreign key constraints with cascade delete - -### ✅ Validation -- Zod schemas for all 35 environment variables -- Type-safe, runtime-safe validation -- Custom refinements for URLs, ranges, formats -- Automatic validation on set operations - -### ✅ Admin API (6 Endpoints) -``` -GET /api/v1/admin/configs/:environment?key=MAX_RETRIES -POST /api/v1/admin/configs (create/update with audit) -DELETE /api/v1/admin/configs/:environment/:key -GET /api/v1/admin/configs/:environment/audit -POST /api/v1/admin/configs/export/:environment -POST /api/v1/admin/configs/import/:environment -``` - -### ✅ Bulk Import Script -```bash -tsx scripts/import-configs.ts prod-us-east ./config-prod.json admin@example.com "Initial prod import" -``` - -### ✅ Startup Validation -- Validates all required configurations before starting -- Prevents runtime crashes due to missing config -- Logs warnings for optional configurations - -## Files Changed - -### Database -- `backend/src/database/migrations/023_config_service.ts` — Migration for configs + config_audits tables - -### Core Service -- `backend/src/services/config-service/ConfigService.ts` — Core service with hierarchical resolution, caching, audit -- `backend/src/services/config-service/validators.ts` — Zod schemas for all 35 configuration keys -- `backend/src/services/config-service/defaults.ts` — Safe defaults for all configuration keys - -### Admin API -- `backend/src/api/routes/admin/configs.ts` — Admin CRUD endpoints -- `backend/src/api/routes/index.ts` — Register admin config routes - -### Scripts -- `backend/scripts/import-configs.ts` — Bulk import script - -### Bootstrap -- `backend/src/bootstrap/validateConfig.ts` — Startup validation - -### Tests -- `backend/src/services/config-service/__tests__/ConfigService.test.ts` — Comprehensive tests (24 tests) - -### Documentation -- `backend/src/services/config-service/README.md` — Complete usage guide -- `backend/services/config-service/RECON-REPORT.md` — Reconnaissance report -- `backend/services/config-service/ARCHITECTURE.md` — System design & data flows -- `RECON_SUMMARY.md` — Executive summary -- `RECONNAISSANCE_INDEX.md` — Documentation index -- `RECON_VERIFICATION_CHECKLIST.md` — Verification checklist -- `IMPLEMENTATION_READY.md` — Implementation summary - -## Architecture - -### Hierarchical Resolution -``` -1. Environment-specific config (prod-us-east) - ↓ (if not found) -2. Global config (shared across all) - ↓ (if not found) -3. Safe default (embedded) - ↓ (if not found) -4. Error (required config missing) -``` - -### Cache Strategy -- **TTL:** 5 minutes (300 seconds) -- **Prefix:** `config:environment:key` -- **Invalidation:** Redis pub/sub on every change -- **Cluster:** All instances subscribe to `config:changed` channel -- **Performance:** Sub-millisecond cache hits (99% path) - -### Audit Trail -Every configuration change records: -- `config_id` — Which config changed -- `old_value` — Previous value (JSONB) -- `new_value` — New value (JSONB) -- `changed_by` — Who changed it (user/service account) -- `change_reason` — Why it changed -- `changed_at` — When it changed (timestamp with timezone) - -### Encryption -Sensitive configuration keys are automatically encrypted at rest: -- JWT_SECRET, CONFIG_ENCRYPTION_KEY, WS_AUTH_SECRET -- CIRCLE_API_KEY, COINBASE_API_KEY, COINBASE_API_SECRET -- COINMARKETCAP_API_KEY, COINGECKO_API_KEY, ONEINCH_API_KEY -- DISCORD_BOT_TOKEN, SMTP_PASSWORD -- POSTGRES_PASSWORD, REDIS_PASSWORD -- API_KEY_BOOTSTRAP_TOKEN - -## Testing - -### Test Coverage -- ✅ Hierarchical resolution (env → global → default) -- ✅ Cache hit/miss scenarios -- ✅ Validation with Zod schemas -- ✅ Encryption for sensitive values -- ✅ Audit trail creation -- ✅ Cache invalidation (local + pub/sub) -- ✅ Bulk import/export -- ✅ Error handling - -### Run Tests -```bash -npm run test config-service -npm run test:coverage config-service -``` - -## Usage Examples - -### Get Configuration -```typescript -import { ConfigService } from "./services/config-service/ConfigService.js"; - -const maxRetries = await configService.get("MAX_RETRIES", "prod-us-east"); -// Returns: 5 (from prod-us-east) OR 3 (from global) OR 3 (safe default) -``` - -### Set Configuration -```typescript -await configService.set("MAX_RETRIES", 5, { - environment: "prod-us-east", - changedBy: "admin@example.com", - changeReason: "Increase for peak load", -}); -``` - -### Get Audit Trail -```typescript -const audits = await configService.getAuditTrail("MAX_RETRIES", "prod-us-east"); -``` - -### Bulk Import -```bash -tsx scripts/import-configs.ts prod-us-east ./config-prod.json admin@example.com "Initial prod import" -``` - -### Admin API -```bash -# Get all configs -curl http://localhost:3001/api/v1/admin/configs/prod-us-east - -# Set config -curl -X POST http://localhost:3001/api/v1/admin/configs \ - -H "Content-Type: application/json" \ - -d '{ - "environment": "prod-us-east", - "key": "MAX_RETRIES", - "value": 5, - "changedBy": "admin@example.com", - "changeReason": "Increase for peak load" - }' - -# Get audit trail -curl http://localhost:3001/api/v1/admin/configs/prod-us-east/audit?key=MAX_RETRIES -``` - -## Deployment - -### 1. Run Migration -```bash -npm run migrate:up -``` - -### 2. Import Initial Configs -```bash -tsx scripts/import-configs.ts prod-us-east ./config-prod.json admin@example.com "Initial prod import" -``` - -### 3. Verify -```bash -curl http://localhost:3001/api/v1/admin/configs/prod-us-east -``` - -## Benefits - -### Zero-Downtime Deployments -- Cache TTL prevents stale reads during rollout -- Pub/sub invalidation ensures cluster coherence -- Hierarchical resolution allows gradual rollout (global → env-specific) - -### Full Audit Trail -- Every change tracked (who/when/why) -- Immutable append-only log -- Enables compliance & debugging - -### Type Safety -- Zod validation for all 35 variables -- Runtime-safe configuration -- Prevents invalid values - -### Cluster Coherence -- Redis pub/sub invalidation -- All instances have fresh cache -- No stale configuration - -### Safe Defaults -- Embedded production-safe defaults -- Prevents crashes due to missing config -- Graceful degradation - -### Hierarchical Resolution -- Environment-specific overrides -- Global fallback for shared config -- Safe defaults as last resort - -### Encryption at Rest -- Sensitive values encrypted in database -- Decrypted only when needed -- Secure secret management - -### Bulk Operations -- Atomic import/export -- Enables config backup & restore -- Supports infrastructure-as-code - -## Configuration Keys - -All 35 environment variables have Zod validation schemas: - -- **Application:** NODE_ENV, PORT, WS_PORT -- **Database:** POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD -- **Redis:** REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_CACHE_TTL_SEC, REDIS_CLUSTER -- **Stellar:** STELLAR_NETWORK, STELLAR_HORIZON_URL, SOROBAN_RPC_URL, SOROBAN_MAINNET_RPC_URL, HORIZON_TIMEOUT_MS, CIRCUIT_BREAKER_CONTRACT_ID, LIQUIDITY_CONTRACT_ADDRESS -- **EVM Chains:** RPC_PROVIDER_TYPE, ETHEREUM_RPC_URL, ETHEREUM_RPC_WS_URL, ETHEREUM_RPC_FALLBACK_URL, POLYGON_RPC_URL, POLYGON_RPC_FALLBACK_URL, BASE_RPC_URL, BASE_RPC_FALLBACK_URL -- **Token & Bridge Addresses:** USDC_TOKEN_ADDRESS, USDC_BRIDGE_ADDRESS, EURC_TOKEN_ADDRESS, EURC_BRIDGE_ADDRESS -- **External APIs:** CIRCLE_API_KEY, CIRCLE_API_URL, CIRCLE_API_TIMEOUT_MS, CIRCLE_CACHE_TTL_SEC, CIRCLE_RATE_LIMIT_MAX, CIRCLE_RATE_LIMIT_WINDOW_MS, COINBASE_API_KEY, COINBASE_API_SECRET, COINMARKETCAP_API_KEY, COINGECKO_API_KEY, ONEINCH_API_KEY -- **Security:** JWT_SECRET, CONFIG_ENCRYPTION_KEY, WS_AUTH_SECRET, API_KEY_BOOTSTRAP_TOKEN -- **Rate Limiting:** RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, RATE_LIMIT_BURST_MULTIPLIER, RATE_LIMIT_WHITELIST_IPS, RATE_LIMIT_WHITELIST_KEYS, RATE_LIMIT_ENABLE_DYNAMIC, RATE_LIMIT_GLOBAL_ALERT_THRESHOLD, RATE_LIMIT_BURST_ALERT_THRESHOLD, RATE_LIMIT_SUSTAINED_ALERT_THRESHOLD, RATE_LIMIT_STATS_RETENTION_HOURS, RATE_LIMIT_ENABLE_MONITORING, RATE_LIMIT_ADMIN_API_KEY_PREFIX, RATE_LIMIT_ENDPOINT_ASSETS, RATE_LIMIT_ENDPOINT_BRIDGES, RATE_LIMIT_ENDPOINT_ALERTS, RATE_LIMIT_ENDPOINT_ANALYTICS, RATE_LIMIT_ENDPOINT_CONFIG, RATE_LIMIT_ENDPOINT_HEALTH -- **Alert Thresholds:** PRICE_DEVIATION_THRESHOLD, BRIDGE_SUPPLY_MISMATCH_THRESHOLD -- **Verification & Retries:** RETRY_MAX, BRIDGE_VERIFICATION_INTERVAL_MS -- **Price Aggregation:** REDIS_PRICE_CACHE_PREFIX -- **Health Score Weights:** HEALTH_WEIGHT_LIQUIDITY, HEALTH_WEIGHT_PRICE, HEALTH_WEIGHT_BRIDGE, HEALTH_WEIGHT_RESERVES, HEALTH_WEIGHT_VOLUME -- **Export Service:** EXPORT_STORAGE_PATH, EXPORT_DOWNLOAD_URL_EXPIRY_HOURS, EXPORT_COMPRESSION_THRESHOLD_BYTES, EXPORT_STREAMING_PAGE_SIZE, EXPORT_QUEUE_CONCURRENCY, EXPORT_MAX_DATE_RANGE_DAYS -- **Logging:** LOG_LEVEL, LOG_FILE, LOG_MAX_FILE_SIZE, LOG_MAX_FILES, LOG_RETENTION_DAYS, LOG_REQUEST_BODY, LOG_RESPONSE_BODY, LOG_SENSITIVE_DATA, REQUEST_SLOW_THRESHOLD_MS -- **Email:** SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_ADDRESS, SMTP_FROM_NAME -- **Discord:** DISCORD_BOT_TOKEN, DISCORD_CLIENT_ID -- **Health Check:** HEALTH_CHECK_TIMEOUT_MS, HEALTH_CHECK_INTERVAL_MS, HEALTH_CHECK_MEMORY_THRESHOLD, HEALTH_CHECK_DISK_THRESHOLD, HEALTH_CHECK_EXTERNAL_APIS -- **Data Validation:** VALIDATION_STRICT_MODE, VALIDATION_ADMIN_BYPASS, VALIDATION_BATCH_SIZE, VALIDATION_MAX_BATCH_SIZE, VALIDATION_DUPLICATE_CHECK, VALIDATION_NORMALIZATION, VALIDATION_CONSISTENCY_CHECKS, VALIDATION_ERROR_THRESHOLD, VALIDATION_WARNING_THRESHOLD, VALIDATION_DATA_QUALITY_THRESHOLD - -## Deployment Environments - -- `global` — Shared across all environments -- `dev` — Development -- `staging` — Staging -- `prod-us-east` — US East production -- `prod-eu-west` — EU West production - -## Breaking Changes - -None. This is a new feature that does not affect existing functionality. - -## Checklist - -- [x] Reconnaissance completed (35 env vars mapped) -- [x] Database migration created (configs + config_audits tables) -- [x] Zod validation schemas created (all 35 vars) -- [x] Safe defaults created (all 35 vars) -- [x] ConfigService implemented (hierarchical resolution, caching, audit) -- [x] Cache invalidation implemented (Redis pub/sub) -- [x] Encryption implemented (AES-256-GCM for sensitive values) -- [x] Admin API implemented (6 endpoints) -- [x] Bulk import script created -- [x] Startup validation created -- [x] Tests written (24 tests, comprehensive coverage) -- [x] Documentation written (README + architecture docs) -- [x] Routes registered in main routes file - -## Screenshots - -### Database Tables -```sql -SELECT * FROM configs LIMIT 5; -SELECT * FROM config_audits LIMIT 5; -``` - -### Admin API -```bash -curl http://localhost:3001/api/v1/admin/configs/global -``` - -### Bulk Import -```bash -tsx scripts/import-configs.ts global ./config-global.json system "Initial global config" -``` - -## Related Issues - -- Issue #377: Build Environment Configuration Service with Full Audit Trail - -## Next Steps - -1. Review and approve PR -2. Run migration in staging: `npm run migrate:up` -3. Import initial configs: `tsx scripts/import-configs.ts staging ./config-staging.json admin@example.com "Initial staging import"` -4. Verify in staging -5. Deploy to production -6. Import production configs -7. Monitor audit trail and cache performance - -## Questions? - -See documentation: -- `backend/src/services/config-service/README.md` — Complete usage guide -- `backend/services/config-service/ARCHITECTURE.md` — System design & data flows -- `RECON_SUMMARY.md` — Executive summary -- `RECONNAISSANCE_INDEX.md` — Documentation index diff --git a/backend/src/api/routes/incidents.ts b/backend/src/api/routes/incidents.ts new file mode 100644 index 00000000..4a38c9b0 --- /dev/null +++ b/backend/src/api/routes/incidents.ts @@ -0,0 +1,100 @@ +import type { FastifyInstance } from "fastify"; +import { escalationService } from "../../services/escalation.service"; + +export async function incidentsRoutes(server: FastifyInstance) { + // Create incident + server.post("/", async (request, reply) => { + const incident = await escalationService.createIncident( + request.body as any, + ); + return reply.code(201).send(incident); + }); + + // Get incident + server.get<{ Params: { incidentId: string } }>( + "/:incidentId", + async (request, reply) => { + const incident = await escalationService.getIncident( + request.params.incidentId, + ); + if (!incident) { + return reply.code(404).send({ error: "Incident not found" }); + } + return incident; + }, + ); + + // Acknowledge incident + server.post<{ + Params: { incidentId: string }; + Body: { acknowledgedBy: string }; + }>("/:incidentId/acknowledge", async (request, reply) => { + await escalationService.acknowledgeIncident( + request.params.incidentId, + request.body.acknowledgedBy, + ); + return reply.code(200).send({ message: "Incident acknowledged" }); + }); + + // Resolve incident + server.post<{ Params: { incidentId: string }; Body: { resolvedBy: string } }>( + "/:incidentId/resolve", + async (request, reply) => { + await escalationService.resolveIncident( + request.params.incidentId, + request.body.resolvedBy, + ); + return reply.code(200).send({ message: "Incident resolved" }); + }, + ); + + // Escalate incident manually + server.post<{ Params: { incidentId: string }; Body: { reason: string } }>( + "/:incidentId/escalate", + async (request, reply) => { + await escalationService.escalateIncident( + request.params.incidentId, + request.body.reason, + "manual", + ); + return reply.code(200).send({ message: "Incident escalated" }); + }, + ); + + // Get escalation history + server.get<{ Params: { incidentId: string } }>( + "/:incidentId/history", + async (request, _reply) => { + const history = await escalationService.getEscalationHistory( + request.params.incidentId, + ); + return { history, total: history.length }; + }, + ); + + // Create escalation rule + server.post("/rules", async (request, reply) => { + const rule = await escalationService.createEscalationRule( + request.body as any, + ); + return reply.code(201).send(rule); + }); + + // Get all escalation rules + server.get("/rules", async (_request, _reply) => { + const rules = await escalationService.getAllRules(); + return { rules, total: rules.length }; + }); + + // Start escalation engine + server.post("/engine/start", async (_request, reply) => { + escalationService.startEngine(); + return reply.code(200).send({ message: "Escalation engine started" }); + }); + + // Stop escalation engine + server.post("/engine/stop", async (_request, reply) => { + escalationService.stopEngine(); + return reply.code(200).send({ message: "Escalation engine stopped" }); + }); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 5a5de1b9..d7176437 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -45,6 +45,8 @@ import { reconciliationRoutes } from "./reconciliation.js"; import { statusSubscriptionsRoutes } from "./statusSubscriptions.js"; import { externalRateLimitMetricsRoutes } from "./externalRateLimitMetrics.routes.js"; import { eventSubscriptionFilterRoutes } from "./eventSubscriptionFilter.routes.js"; +import { maintenanceRoutes } from "./maintenance.js"; +import { notificationTemplatesRoutes } from "./notificationTemplates.js"; import { archivedDataBrowserRoutes } from "./archivedDataBrowser.routes.js"; export async function registerRoutes(server: FastifyInstance) { @@ -70,8 +72,12 @@ export async function registerRoutes(server: FastifyInstance) { server.register(healthRoutes, { prefix: "/health" }); server.register(rateLimitAdminRoutes, { prefix: "/api/v1/admin/rate-limit" }); server.register(tracingAdminRoutes, { prefix: "/api/v1/admin/tracing" }); - server.register(validationAdminRoutes, { prefix: "/api/v1/admin/validation" }); - server.register(alertRoutingAdminRoutes, { prefix: "/api/v1/admin/alert-routing" }); + server.register(validationAdminRoutes, { + prefix: "/api/v1/admin/validation", + }); + server.register(alertRoutingAdminRoutes, { + prefix: "/api/v1/admin/alert-routing", + }); server.register(metricsRoutes, { prefix: "/metrics" }); server.register(priceFeedsRoutes, { prefix: "/api/v1/price-feeds" }); server.register(supplyChainRoutes, { prefix: "/api/v1/supply-chain" }); @@ -85,20 +91,34 @@ export async function registerRoutes(server: FastifyInstance) { server.register(auditRoutes, { prefix: "/api/v1/admin/audit" }); server.register(bridgeRegistryRoutes, { prefix: "/api/v1/bridge-registry" }); server.register(incidentRoutes, { prefix: "/api/v1/incidents" }); - server.register(healthScoreHistoryRoutes, { prefix: "/api/v1/health-score-history" }); + server.register(healthScoreHistoryRoutes, { + prefix: "/api/v1/health-score-history", + }); server.register(horizonStreamRoutes, { prefix: "/api/v1/horizon-streams" }); server.register(adminRotationRoutes, { prefix: "/api/v1/admin/rotation" }); server.register(digestSchedulerRoutes, { prefix: "/api/v1/digest" }); - server.register(alertSuppressionRoutes, { prefix: "/api/v1/alert-suppression" }); - server.register(externalDependenciesRoutes, { prefix: "/api/v1/external-dependencies" }); - server.register(providerHealthRegistryRoutes, { prefix: "/api/v1/providers/health" }); + server.register(alertSuppressionRoutes, { + prefix: "/api/v1/alert-suppression", + }); + server.register(externalDependenciesRoutes, { + prefix: "/api/v1/external-dependencies", + }); + server.register(providerHealthRegistryRoutes, { + prefix: "/api/v1/providers/health", + }); server.register(reconciliationRoutes, { prefix: "/api/v1/reconciliation" }); - server.register(statusSubscriptionsRoutes, { prefix: "/api/v1/status-subscriptions" }); + server.register(statusSubscriptionsRoutes, { + prefix: "/api/v1/status-subscriptions", + }); server.register(externalRateLimitMetricsRoutes, { prefix: "/api/v1/metrics/external-rate-limits", }); server.register(eventSubscriptionFilterRoutes, { prefix: "/api/v1/event-subscriptions", }); + server.register(maintenanceRoutes, { prefix: "/api/v1/maintenance" }); + server.register(notificationTemplatesRoutes, { + prefix: "/api/v1/notification-templates", + }); server.register(archivedDataBrowserRoutes, { prefix: "/api/v1/archive" }); } diff --git a/backend/src/api/routes/maintenance.ts b/backend/src/api/routes/maintenance.ts new file mode 100644 index 00000000..fdf7a031 --- /dev/null +++ b/backend/src/api/routes/maintenance.ts @@ -0,0 +1,109 @@ +import type { FastifyInstance } from "fastify"; +import { + maintenanceService, + MaintenanceScope, + MaintenanceStatus, +} from "../../services/maintenance.service"; + +export async function maintenanceRoutes(server: FastifyInstance) { + // Create maintenance window + server.post("/", async (request, reply) => { + const window = await maintenanceService.createWindow(request.body as any); + return reply.code(201).send(window); + }); + + // Get maintenance window + server.get<{ Params: { windowId: string } }>( + "/:windowId", + async (request, reply) => { + const window = await maintenanceService.getWindow( + request.params.windowId, + ); + if (!window) { + return reply.code(404).send({ error: "Window not found" }); + } + return window; + }, + ); + + // Update maintenance window + server.patch<{ + Params: { windowId: string }; + Body: { updates: any; updatedBy: string }; + }>("/:windowId", async (request, reply) => { + const window = await maintenanceService.updateWindow( + request.params.windowId, + request.body.updates, + request.body.updatedBy, + ); + if (!window) { + return reply.code(404).send({ error: "Window not found" }); + } + return window; + }); + + // Approve maintenance window + server.post<{ Params: { windowId: string }; Body: { approvedBy: string } }>( + "/:windowId/approve", + async (request, reply) => { + await maintenanceService.approveWindow( + request.params.windowId, + request.body.approvedBy, + ); + return reply.code(200).send({ message: "Window approved" }); + }, + ); + + // Cancel maintenance window + server.post<{ Params: { windowId: string }; Body: { cancelledBy: string } }>( + "/:windowId/cancel", + async (request, reply) => { + await maintenanceService.cancelWindow( + request.params.windowId, + request.body.cancelledBy, + ); + return reply.code(200).send({ message: "Window cancelled" }); + }, + ); + + // Get active windows + server.get("/active", async (_request, _reply) => { + const windows = await maintenanceService.getActiveWindows(); + return { windows, total: windows.length }; + }); + + // Get upcoming windows + server.get("/upcoming", async (request, _reply) => { + const limit = (request.query as any).limit || 10; + const windows = await maintenanceService.getUpcomingWindows(limit); + return { windows, total: windows.length }; + }); + + // Get all windows with filters + server.get("/", async (request, _reply) => { + const filters = request.query as any; + const windows = await maintenanceService.getAllWindows(filters); + return { windows, total: windows.length }; + }); + + // Get audit trail + server.get<{ Params: { windowId: string } }>( + "/:windowId/audit", + async (request, _reply) => { + const trail = await maintenanceService.getAuditTrail( + request.params.windowId, + ); + return { trail, total: trail.length }; + }, + ); + + // Check alert suppression + server.post("/check-suppression", async (request, _reply) => { + const { alertType, scope } = request.body as any; + const suppressed = await maintenanceService.shouldSuppressAlert( + alertType, + scope, + ); + return { suppressed }; + }); +} diff --git a/backend/src/api/routes/notificationTemplates.ts b/backend/src/api/routes/notificationTemplates.ts new file mode 100644 index 00000000..7623b7ac --- /dev/null +++ b/backend/src/api/routes/notificationTemplates.ts @@ -0,0 +1,124 @@ +import type { FastifyInstance } from "fastify"; +import { + notificationTemplateService, + TemplateChannel, + TemplateStatus, +} from "../../services/notificationTemplate.service"; + +export async function notificationTemplatesRoutes(server: FastifyInstance) { + // Create template + server.post("/", async (request, reply) => { + const template = await notificationTemplateService.createTemplate( + request.body as any, + ); + return reply.code(201).send(template); + }); + + // Get template + server.get<{ Params: { templateId: string } }>( + "/:templateId", + async (request, reply) => { + const template = await notificationTemplateService.getTemplate( + request.params.templateId, + ); + if (!template) { + return reply.code(404).send({ error: "Template not found" }); + } + return template; + }, + ); + + // Update template + server.patch<{ + Params: { templateId: string }; + Body: { updates: any; updatedBy: string }; + }>("/:templateId", async (request, reply) => { + const template = await notificationTemplateService.updateTemplate( + request.params.templateId, + request.body.updates, + request.body.updatedBy, + ); + if (!template) { + return reply.code(404).send({ error: "Template not found" }); + } + return template; + }); + + // Submit for approval + server.post<{ Params: { templateId: string } }>( + "/:templateId/submit", + async (request, reply) => { + await notificationTemplateService.submitForApproval( + request.params.templateId, + ); + return reply + .code(200) + .send({ message: "Template submitted for approval" }); + }, + ); + + // Approve template + server.post<{ Params: { templateId: string }; Body: { approvedBy: string } }>( + "/:templateId/approve", + async (request, reply) => { + await notificationTemplateService.approveTemplate( + request.params.templateId, + request.body.approvedBy, + ); + return reply.code(200).send({ message: "Template approved" }); + }, + ); + + // Archive template + server.post<{ Params: { templateId: string } }>( + "/:templateId/archive", + async (request, reply) => { + await notificationTemplateService.archiveTemplate( + request.params.templateId, + ); + return reply.code(200).send({ message: "Template archived" }); + }, + ); + + // Preview template + server.post<{ + Params: { templateId: string }; + Body: { variables: Record }; + }>("/:templateId/preview", async (request, _reply) => { + const preview = await notificationTemplateService.previewTemplate( + request.params.templateId, + request.body.variables, + ); + return preview; + }); + + // Validate variables + server.post("/validate", async (request, _reply) => { + const { body, subject, variables } = request.body as any; + const validation = notificationTemplateService.validateVariables( + body, + subject, + variables, + ); + return validation; + }); + + // Get all templates + server.get("/", async (request, _reply) => { + const filters = request.query as any; + const templates = + await notificationTemplateService.getAllTemplates(filters); + return { templates, total: templates.length }; + }); + + // Get template versions + server.get<{ Params: { templateId: string } }>( + "/:templateId/versions", + async (request, _reply) => { + const versions = await notificationTemplateService.getTemplateVersions( + request.params.templateId, + ); + return { versions, total: versions.length }; + }, + ); +} diff --git a/backend/src/database/migrations/011_maintenance_windows.ts b/backend/src/database/migrations/011_maintenance_windows.ts new file mode 100644 index 00000000..cc7970bc --- /dev/null +++ b/backend/src/database/migrations/011_maintenance_windows.ts @@ -0,0 +1,60 @@ +import { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + // Maintenance windows table + await knex.schema.createTable("maintenance_windows", (table) => { + table.string("id").primary(); + table.string("title").notNullable(); + table.text("description"); + table.enum("scope", ["global", "bridge", "asset", "service"]).notNullable(); + table.string("scope_identifier"); + table.timestamp("start_time").notNullable(); + table.timestamp("end_time").notNullable(); + table + .enum("status", ["scheduled", "active", "completed", "cancelled"]) + .notNullable() + .defaultTo("scheduled"); + table.boolean("suppress_alerts").notNullable().defaultTo(true); + table.jsonb("alert_types_suppressed").notNullable().defaultTo("[]"); + table.string("created_by").notNullable(); + table.string("approved_by"); + table.timestamp("approved_at"); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + table.string("timezone").notNullable().defaultTo("UTC"); + + table.index(["status", "start_time"]); + table.index(["scope", "scope_identifier"]); + }); + + // Maintenance audit logs table + await knex.schema.createTable("maintenance_audit_logs", (table) => { + table.string("id").primary(); + table + .string("window_id") + .notNullable() + .references("id") + .inTable("maintenance_windows") + .onDelete("CASCADE"); + table + .enum("action", [ + "created", + "updated", + "started", + "completed", + "cancelled", + "approved", + ]) + .notNullable(); + table.string("performed_by").notNullable(); + table.jsonb("details").notNullable().defaultTo("{}"); + table.timestamp("timestamp").notNullable().defaultTo(knex.fn.now()); + + table.index(["window_id", "timestamp"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("maintenance_audit_logs"); + await knex.schema.dropTableIfExists("maintenance_windows"); +} diff --git a/backend/src/database/migrations/012_notification_templates.ts b/backend/src/database/migrations/012_notification_templates.ts new file mode 100644 index 00000000..3264a5b5 --- /dev/null +++ b/backend/src/database/migrations/012_notification_templates.ts @@ -0,0 +1,53 @@ +import { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + // Notification templates table + await knex.schema.createTable("notification_templates", (table) => { + table.string("id").primary(); + table.string("name").notNullable(); + table.text("description"); + table.enum("channel", ["email", "webhook", "in_app", "sms"]).notNullable(); + table.text("subject"); + table.text("body").notNullable(); + table.jsonb("variables").notNullable().defaultTo("[]"); + table.jsonb("metadata").notNullable().defaultTo("{}"); + table + .enum("status", ["draft", "pending_approval", "approved", "archived"]) + .notNullable() + .defaultTo("draft"); + table.integer("version").notNullable().defaultTo(1); + table.string("created_by").notNullable(); + table.string("approved_by"); + table.timestamp("approved_at"); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + + table.index(["channel", "status"]); + table.index(["name"]); + }); + + // Template versions table + await knex.schema.createTable("template_versions", (table) => { + table.string("id").primary(); + table + .string("template_id") + .notNullable() + .references("id") + .inTable("notification_templates") + .onDelete("CASCADE"); + table.integer("version").notNullable(); + table.text("subject"); + table.text("body").notNullable(); + table.jsonb("variables").notNullable().defaultTo("[]"); + table.string("created_by").notNullable(); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + + table.unique(["template_id", "version"]); + table.index(["template_id", "version"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("template_versions"); + await knex.schema.dropTableIfExists("notification_templates"); +} diff --git a/backend/src/database/migrations/013_incident_escalation.ts b/backend/src/database/migrations/013_incident_escalation.ts new file mode 100644 index 00000000..68069adc --- /dev/null +++ b/backend/src/database/migrations/013_incident_escalation.ts @@ -0,0 +1,75 @@ +import { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + // Incidents table + await knex.schema.createTable("incidents", (table) => { + table.string("id").primary(); + table.string("title").notNullable(); + table.text("description"); + table.enum("severity", ["low", "medium", "high", "critical"]).notNullable(); + table + .enum("status", [ + "open", + "acknowledged", + "investigating", + "resolved", + "closed", + ]) + .notNullable() + .defaultTo("open"); + table.integer("current_escalation_level").notNullable().defaultTo(1); + table.string("assigned_to"); + table.timestamp("acknowledged_at"); + table.string("acknowledged_by"); + table.timestamp("resolved_at"); + table.string("resolved_by"); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + + table.index(["status", "severity"]); + table.index(["created_at"]); + }); + + // Escalation rules table + await knex.schema.createTable("escalation_rules", (table) => { + table.string("id").primary(); + table.string("name").notNullable(); + table.enum("severity", ["low", "medium", "high", "critical"]).notNullable(); + table.integer("from_level").notNullable(); + table.integer("to_level").notNullable(); + table.integer("timeout_minutes").notNullable(); + table.boolean("require_acknowledgement").notNullable().defaultTo(false); + table.jsonb("notification_channels").notNullable().defaultTo("[]"); + table.jsonb("route_to").notNullable().defaultTo("[]"); + table.boolean("is_active").notNullable().defaultTo(true); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + + table.index(["severity", "from_level"]); + }); + + // Escalation history table + await knex.schema.createTable("escalation_history", (table) => { + table.string("id").primary(); + table + .string("incident_id") + .notNullable() + .references("id") + .inTable("incidents") + .onDelete("CASCADE"); + table.integer("from_level").notNullable(); + table.integer("to_level").notNullable(); + table.text("reason").notNullable(); + table.enum("escalated_by", ["system", "manual"]).notNullable(); + table.timestamp("escalated_at").notNullable().defaultTo(knex.fn.now()); + table.jsonb("notified_users").notNullable().defaultTo("[]"); + + table.index(["incident_id", "escalated_at"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("escalation_history"); + await knex.schema.dropTableIfExists("escalation_rules"); + await knex.schema.dropTableIfExists("incidents"); +} diff --git a/backend/src/services/escalation.service.ts b/backend/src/services/escalation.service.ts new file mode 100644 index 00000000..a01da1a9 --- /dev/null +++ b/backend/src/services/escalation.service.ts @@ -0,0 +1,511 @@ +/** + * Incident Escalation Engine + * Automatically escalates unresolved incidents based on severity, duration, and routing rules + */ + +import { getDatabase } from "../database/connection"; +import { logger } from "../utils/logger"; +import { randomBytes } from "crypto"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type IncidentSeverity = "low" | "medium" | "high" | "critical"; +export type IncidentStatus = + | "open" + | "acknowledged" + | "investigating" + | "resolved" + | "closed"; +export type EscalationLevel = 1 | 2 | 3 | 4 | 5; + +export interface Incident { + id: string; + title: string; + description: string; + severity: IncidentSeverity; + status: IncidentStatus; + current_escalation_level: EscalationLevel; + assigned_to: string | null; + acknowledged_at: Date | null; + acknowledged_by: string | null; + resolved_at: Date | null; + resolved_by: string | null; + created_at: Date; + updated_at: Date; +} + +export interface EscalationRule { + id: string; + name: string; + severity: IncidentSeverity; + from_level: EscalationLevel; + to_level: EscalationLevel; + timeout_minutes: number; + require_acknowledgement: boolean; + notification_channels: string[]; + route_to: string[]; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface EscalationHistory { + id: string; + incident_id: string; + from_level: EscalationLevel; + to_level: EscalationLevel; + reason: string; + escalated_by: "system" | "manual"; + escalated_at: Date; + notified_users: string[]; +} + +// ─── Escalation Service ────────────────────────────────────────────────────── + +export class EscalationService { + private readonly CHECK_INTERVAL_MS = 60000; // 1 minute + private isRunning = false; + + /** + * Create incident + */ + async createIncident( + incident: Omit< + Incident, + | "id" + | "current_escalation_level" + | "acknowledged_at" + | "acknowledged_by" + | "resolved_at" + | "resolved_by" + | "created_at" + | "updated_at" + >, + ): Promise { + const db = getDatabase(); + + try { + const incidentId = randomBytes(16).toString("hex"); + + const newIncident = { + id: incidentId, + ...incident, + current_escalation_level: 1, + acknowledged_at: null, + acknowledged_by: null, + resolved_at: null, + resolved_by: null, + created_at: new Date(), + updated_at: new Date(), + }; + + await db("incidents").insert(newIncident); + + // Start escalation monitoring + this.monitorIncident(incidentId); + + logger.info( + { incidentId, severity: incident.severity }, + "Incident created", + ); + + return newIncident as Incident; + } catch (error) { + logger.error({ error }, "Failed to create incident"); + throw error; + } + } + + /** + * Acknowledge incident + */ + async acknowledgeIncident( + incidentId: string, + acknowledgedBy: string, + ): Promise { + const db = getDatabase(); + + try { + await db("incidents").where({ id: incidentId }).update({ + status: "acknowledged", + acknowledged_at: new Date(), + acknowledged_by: acknowledgedBy, + updated_at: new Date(), + }); + + logger.info({ incidentId, acknowledgedBy }, "Incident acknowledged"); + } catch (error) { + logger.error({ error, incidentId }, "Failed to acknowledge incident"); + throw error; + } + } + + /** + * Resolve incident + */ + async resolveIncident(incidentId: string, resolvedBy: string): Promise { + const db = getDatabase(); + + try { + await db("incidents").where({ id: incidentId }).update({ + status: "resolved", + resolved_at: new Date(), + resolved_by: resolvedBy, + updated_at: new Date(), + }); + + logger.info({ incidentId, resolvedBy }, "Incident resolved"); + } catch (error) { + logger.error({ error, incidentId }, "Failed to resolve incident"); + throw error; + } + } + + /** + * Escalate incident + */ + async escalateIncident( + incidentId: string, + reason: string, + escalatedBy: "system" | "manual" = "system", + ): Promise { + const db = getDatabase(); + + try { + const incident = await db("incidents").where({ id: incidentId }).first(); + + if (!incident) { + throw new Error("Incident not found"); + } + + // Get escalation rule + const rule = await this.getEscalationRule( + incident.severity, + incident.current_escalation_level, + ); + + if (!rule) { + logger.warn({ incidentId }, "No escalation rule found"); + return; + } + + const newLevel = rule.to_level; + + // Update incident + await db("incidents").where({ id: incidentId }).update({ + current_escalation_level: newLevel, + updated_at: new Date(), + }); + + // Log escalation + await this.logEscalation( + incidentId, + incident.current_escalation_level, + newLevel, + reason, + escalatedBy, + rule.route_to, + ); + + // Send notifications + await this.sendEscalationNotifications(incident, rule); + + logger.info( + { + incidentId, + fromLevel: incident.current_escalation_level, + toLevel: newLevel, + }, + "Incident escalated", + ); + } catch (error) { + logger.error({ error, incidentId }, "Failed to escalate incident"); + throw error; + } + } + + /** + * Monitor incident for escalation + */ + private async monitorIncident(incidentId: string): Promise { + const db = getDatabase(); + + try { + const incident = await db("incidents").where({ id: incidentId }).first(); + + if ( + !incident || + incident.status === "resolved" || + incident.status === "closed" + ) { + return; + } + + // Get escalation rule + const rule = await this.getEscalationRule( + incident.severity, + incident.current_escalation_level, + ); + + if (!rule) { + return; + } + + // Check if timeout exceeded + const timeInLevel = Date.now() - new Date(incident.updated_at).getTime(); + const timeoutMs = rule.timeout_minutes * 60 * 1000; + + if (timeInLevel >= timeoutMs) { + // Check if acknowledgement is required + if (rule.require_acknowledgement && !incident.acknowledged_at) { + await this.escalateIncident( + incidentId, + `No acknowledgement after ${rule.timeout_minutes} minutes`, + ); + } else if (!rule.require_acknowledgement) { + await this.escalateIncident( + incidentId, + `Unresolved after ${rule.timeout_minutes} minutes`, + ); + } + } + } catch (error) { + logger.error({ error, incidentId }, "Failed to monitor incident"); + } + } + + /** + * Start escalation engine + */ + startEngine(): void { + if (this.isRunning) { + logger.warn("Escalation engine already running"); + return; + } + + this.isRunning = true; + logger.info("Escalation engine started"); + + const checkEscalations = async () => { + if (!this.isRunning) return; + + try { + await this.processEscalations(); + } catch (error) { + logger.error({ error }, "Error processing escalations"); + } + + setTimeout(checkEscalations, this.CHECK_INTERVAL_MS); + }; + + checkEscalations(); + } + + /** + * Stop escalation engine + */ + stopEngine(): void { + this.isRunning = false; + logger.info("Escalation engine stopped"); + } + + /** + * Process all pending escalations + */ + private async processEscalations(): Promise { + const db = getDatabase(); + + try { + // Get all open incidents + const incidents = await db("incidents") + .whereIn("status", ["open", "acknowledged", "investigating"]) + .orderBy("created_at"); + + for (const incident of incidents) { + await this.monitorIncident(incident.id); + } + } catch (error) { + logger.error({ error }, "Failed to process escalations"); + } + } + + /** + * Get escalation rule + */ + private async getEscalationRule( + severity: IncidentSeverity, + fromLevel: EscalationLevel, + ): Promise { + const db = getDatabase(); + + try { + const rule = await db("escalation_rules") + .where({ + severity, + from_level: fromLevel, + is_active: true, + }) + .first(); + + if (!rule) { + return null; + } + + return { + ...rule, + notification_channels: JSON.parse(rule.notification_channels || "[]"), + route_to: JSON.parse(rule.route_to || "[]"), + }; + } catch (error) { + logger.error( + { error, severity, fromLevel }, + "Failed to get escalation rule", + ); + return null; + } + } + + /** + * Log escalation + */ + private async logEscalation( + incidentId: string, + fromLevel: EscalationLevel, + toLevel: EscalationLevel, + reason: string, + escalatedBy: "system" | "manual", + notifiedUsers: string[], + ): Promise { + const db = getDatabase(); + + try { + await db("escalation_history").insert({ + id: randomBytes(16).toString("hex"), + incident_id: incidentId, + from_level: fromLevel, + to_level: toLevel, + reason, + escalated_by: escalatedBy, + escalated_at: new Date(), + notified_users: JSON.stringify(notifiedUsers), + }); + } catch (error) { + logger.error({ error, incidentId }, "Failed to log escalation"); + } + } + + /** + * Send escalation notifications + */ + private async sendEscalationNotifications( + incident: Incident, + rule: EscalationRule, + ): Promise { + // In production, integrate with notification service + logger.info( + { + incidentId: incident.id, + channels: rule.notification_channels, + recipients: rule.route_to, + }, + "Escalation notifications sent", + ); + } + + /** + * Get incident + */ + async getIncident(incidentId: string): Promise { + const db = getDatabase(); + + try { + return await db("incidents").where({ id: incidentId }).first(); + } catch (error) { + logger.error({ error, incidentId }, "Failed to get incident"); + return null; + } + } + + /** + * Get escalation history + */ + async getEscalationHistory(incidentId: string): Promise { + const db = getDatabase(); + + try { + const history = await db("escalation_history") + .where({ incident_id: incidentId }) + .orderBy("escalated_at", "desc"); + + return history.map((h: any) => ({ + ...h, + notified_users: JSON.parse(h.notified_users || "[]"), + })); + } catch (error) { + logger.error({ error, incidentId }, "Failed to get escalation history"); + return []; + } + } + + /** + * Create escalation rule + */ + async createEscalationRule( + rule: Omit, + ): Promise { + const db = getDatabase(); + + try { + const ruleId = randomBytes(16).toString("hex"); + + const newRule = { + id: ruleId, + ...rule, + notification_channels: JSON.stringify(rule.notification_channels), + route_to: JSON.stringify(rule.route_to), + created_at: new Date(), + updated_at: new Date(), + }; + + await db("escalation_rules").insert(newRule); + + logger.info({ ruleId, name: rule.name }, "Escalation rule created"); + + return { + ...newRule, + notification_channels: rule.notification_channels, + route_to: rule.route_to, + }; + } catch (error) { + logger.error({ error }, "Failed to create escalation rule"); + throw error; + } + } + + /** + * Get all escalation rules + */ + async getAllRules(): Promise { + const db = getDatabase(); + + try { + const rules = await db("escalation_rules") + .where({ is_active: true }) + .orderBy("severity") + .orderBy("from_level"); + + return rules.map((r: any) => ({ + ...r, + notification_channels: JSON.parse(r.notification_channels || "[]"), + route_to: JSON.parse(r.route_to || "[]"), + })); + } catch (error) { + logger.error({ error }, "Failed to get all rules"); + return []; + } + } +} + +// ─── Singleton Instance ────────────────────────────────────────────────────── + +export const escalationService = new EscalationService(); diff --git a/backend/src/services/maintenance.service.ts b/backend/src/services/maintenance.service.ts new file mode 100644 index 00000000..7c937874 --- /dev/null +++ b/backend/src/services/maintenance.service.ts @@ -0,0 +1,462 @@ +/** + * Maintenance Window Scheduler Service + * Manages maintenance windows and suppresses alerts during approved work periods + */ + +import { getDatabase } from "../database/connection"; +import { logger } from "../utils/logger"; +import { randomBytes } from "crypto"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type MaintenanceStatus = + | "scheduled" + | "active" + | "completed" + | "cancelled"; +export type MaintenanceScope = "global" | "bridge" | "asset" | "service"; + +export interface MaintenanceWindow { + id: string; + title: string; + description: string; + scope: MaintenanceScope; + scope_identifier: string | null; + start_time: Date; + end_time: Date; + status: MaintenanceStatus; + suppress_alerts: boolean; + alert_types_suppressed: string[]; + created_by: string; + approved_by: string | null; + approved_at: Date | null; + created_at: Date; + updated_at: Date; + timezone: string; +} + +export interface MaintenanceAuditLog { + id: string; + window_id: string; + action: + | "created" + | "updated" + | "started" + | "completed" + | "cancelled" + | "approved"; + performed_by: string; + details: Record; + timestamp: Date; +} + +// ─── Maintenance Service ───────────────────────────────────────────────────── + +export class MaintenanceService { + /** + * Create maintenance window + */ + async createWindow( + window: Omit< + MaintenanceWindow, + | "id" + | "status" + | "approved_by" + | "approved_at" + | "created_at" + | "updated_at" + >, + ): Promise { + const db = getDatabase(); + + try { + const windowId = randomBytes(16).toString("hex"); + + const newWindow = { + id: windowId, + ...window, + alert_types_suppressed: JSON.stringify(window.alert_types_suppressed), + status: "scheduled" as MaintenanceStatus, + approved_by: null, + approved_at: null, + created_at: new Date(), + updated_at: new Date(), + }; + + await db("maintenance_windows").insert(newWindow); + + // Audit log + await this.logAction(windowId, "created", window.created_by, { + title: window.title, + start_time: window.start_time, + end_time: window.end_time, + }); + + logger.info( + { windowId, title: window.title }, + "Maintenance window created", + ); + + return { + ...newWindow, + alert_types_suppressed: window.alert_types_suppressed, + }; + } catch (error) { + logger.error({ error }, "Failed to create maintenance window"); + throw error; + } + } + + /** + * Get maintenance window + */ + async getWindow(windowId: string): Promise { + const db = getDatabase(); + + try { + const window = await db("maintenance_windows") + .where({ id: windowId }) + .first(); + + if (!window) { + return null; + } + + return { + ...window, + alert_types_suppressed: JSON.parse( + window.alert_types_suppressed || "[]", + ), + }; + } catch (error) { + logger.error({ error, windowId }, "Failed to get maintenance window"); + return null; + } + } + + /** + * Update maintenance window + */ + async updateWindow( + windowId: string, + updates: Partial< + Omit + >, + updatedBy: string, + ): Promise { + const db = getDatabase(); + + try { + const existing = await this.getWindow(windowId); + if (!existing) { + return null; + } + + const updateData: any = { + ...updates, + updated_at: new Date(), + }; + + if (updates.alert_types_suppressed) { + updateData.alert_types_suppressed = JSON.stringify( + updates.alert_types_suppressed, + ); + } + + await db("maintenance_windows") + .where({ id: windowId }) + .update(updateData); + + // Audit log + await this.logAction(windowId, "updated", updatedBy, updates); + + logger.info({ windowId }, "Maintenance window updated"); + + return await this.getWindow(windowId); + } catch (error) { + logger.error({ error, windowId }, "Failed to update maintenance window"); + throw error; + } + } + + /** + * Approve maintenance window + */ + async approveWindow(windowId: string, approvedBy: string): Promise { + const db = getDatabase(); + + try { + await db("maintenance_windows").where({ id: windowId }).update({ + approved_by: approvedBy, + approved_at: new Date(), + updated_at: new Date(), + }); + + await this.logAction(windowId, "approved", approvedBy, {}); + + logger.info({ windowId, approvedBy }, "Maintenance window approved"); + } catch (error) { + logger.error({ error, windowId }, "Failed to approve maintenance window"); + throw error; + } + } + + /** + * Cancel maintenance window + */ + async cancelWindow(windowId: string, cancelledBy: string): Promise { + const db = getDatabase(); + + try { + await db("maintenance_windows").where({ id: windowId }).update({ + status: "cancelled", + updated_at: new Date(), + }); + + await this.logAction(windowId, "cancelled", cancelledBy, {}); + + logger.info({ windowId, cancelledBy }, "Maintenance window cancelled"); + } catch (error) { + logger.error({ error, windowId }, "Failed to cancel maintenance window"); + throw error; + } + } + + /** + * Check if alert should be suppressed + */ + async shouldSuppressAlert( + alertType: string, + scope?: { type: MaintenanceScope; identifier?: string }, + ): Promise { + const db = getDatabase(); + + try { + const now = new Date(); + + let query = db("maintenance_windows") + .where("status", "active") + .where("suppress_alerts", true) + .where("start_time", "<=", now) + .where("end_time", ">=", now); + + // Check scope + if (scope) { + query = query.where((builder) => { + builder.where("scope", "global").orWhere((subBuilder) => { + subBuilder + .where("scope", scope.type) + .where("scope_identifier", scope.identifier || null); + }); + }); + } else { + query = query.where("scope", "global"); + } + + const windows = await query; + + // Check if any window suppresses this alert type + for (const window of windows) { + const suppressedTypes = JSON.parse( + window.alert_types_suppressed || "[]", + ); + if ( + suppressedTypes.includes(alertType) || + suppressedTypes.includes("*") + ) { + logger.debug( + { windowId: window.id, alertType }, + "Alert suppressed by maintenance window", + ); + return true; + } + } + + return false; + } catch (error) { + logger.error({ error, alertType }, "Failed to check alert suppression"); + return false; + } + } + + /** + * Get active maintenance windows + */ + async getActiveWindows(): Promise { + const db = getDatabase(); + + try { + const now = new Date(); + + const windows = await db("maintenance_windows") + .where("status", "active") + .where("start_time", "<=", now) + .where("end_time", ">=", now) + .orderBy("start_time"); + + return windows.map((w: any) => ({ + ...w, + alert_types_suppressed: JSON.parse(w.alert_types_suppressed || "[]"), + })); + } catch (error) { + logger.error({ error }, "Failed to get active windows"); + return []; + } + } + + /** + * Get upcoming maintenance windows + */ + async getUpcomingWindows(limit: number = 10): Promise { + const db = getDatabase(); + + try { + const now = new Date(); + + const windows = await db("maintenance_windows") + .where("status", "scheduled") + .where("start_time", ">", now) + .orderBy("start_time") + .limit(limit); + + return windows.map((w: any) => ({ + ...w, + alert_types_suppressed: JSON.parse(w.alert_types_suppressed || "[]"), + })); + } catch (error) { + logger.error({ error }, "Failed to get upcoming windows"); + return []; + } + } + + /** + * Process maintenance window transitions + */ + async processWindowTransitions(): Promise { + const db = getDatabase(); + + try { + const now = new Date(); + + // Start scheduled windows + const toStart = await db("maintenance_windows") + .where("status", "scheduled") + .where("start_time", "<=", now) + .where("end_time", ">", now); + + for (const window of toStart) { + await db("maintenance_windows") + .where({ id: window.id }) + .update({ status: "active", updated_at: now }); + + await this.logAction(window.id, "started", "system", {}); + logger.info({ windowId: window.id }, "Maintenance window started"); + } + + // Complete active windows + const toComplete = await db("maintenance_windows") + .where("status", "active") + .where("end_time", "<=", now); + + for (const window of toComplete) { + await db("maintenance_windows") + .where({ id: window.id }) + .update({ status: "completed", updated_at: now }); + + await this.logAction(window.id, "completed", "system", {}); + logger.info({ windowId: window.id }, "Maintenance window completed"); + } + } catch (error) { + logger.error({ error }, "Failed to process window transitions"); + } + } + + /** + * Get audit trail + */ + async getAuditTrail(windowId: string): Promise { + const db = getDatabase(); + + try { + const logs = await db("maintenance_audit_logs") + .where({ window_id: windowId }) + .orderBy("timestamp", "desc"); + + return logs.map((log: any) => ({ + ...log, + details: JSON.parse(log.details || "{}"), + })); + } catch (error) { + logger.error({ error, windowId }, "Failed to get audit trail"); + return []; + } + } + + /** + * Log action + */ + private async logAction( + windowId: string, + action: MaintenanceAuditLog["action"], + performedBy: string, + details: Record, + ): Promise { + const db = getDatabase(); + + try { + await db("maintenance_audit_logs").insert({ + id: randomBytes(16).toString("hex"), + window_id: windowId, + action, + performed_by: performedBy, + details: JSON.stringify(details), + timestamp: new Date(), + }); + } catch (error) { + logger.error({ error, windowId, action }, "Failed to log action"); + } + } + + /** + * Get all windows with filters + */ + async getAllWindows(filters?: { + status?: MaintenanceStatus; + scope?: MaintenanceScope; + startDate?: Date; + endDate?: Date; + }): Promise { + const db = getDatabase(); + + try { + let query = db("maintenance_windows"); + + if (filters?.status) { + query = query.where("status", filters.status); + } + if (filters?.scope) { + query = query.where("scope", filters.scope); + } + if (filters?.startDate) { + query = query.where("start_time", ">=", filters.startDate); + } + if (filters?.endDate) { + query = query.where("end_time", "<=", filters.endDate); + } + + const windows = await query.orderBy("start_time", "desc"); + + return windows.map((w: any) => ({ + ...w, + alert_types_suppressed: JSON.parse(w.alert_types_suppressed || "[]"), + })); + } catch (error) { + logger.error({ error }, "Failed to get all windows"); + return []; + } + } +} + +// ─── Singleton Instance ────────────────────────────────────────────────────── + +export const maintenanceService = new MaintenanceService(); diff --git a/backend/src/services/notificationTemplate.service.ts b/backend/src/services/notificationTemplate.service.ts new file mode 100644 index 00000000..eea0eb96 --- /dev/null +++ b/backend/src/services/notificationTemplate.service.ts @@ -0,0 +1,442 @@ +/** + * Notification Template Service + * Manages reusable notification templates for email, webhook, and in-app delivery + */ + +import { getDatabase } from "../database/connection"; +import { logger } from "../utils/logger"; +import { randomBytes } from "crypto"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type TemplateChannel = "email" | "webhook" | "in_app" | "sms"; +export type TemplateStatus = + | "draft" + | "pending_approval" + | "approved" + | "archived"; + +export interface NotificationTemplate { + id: string; + name: string; + description: string; + channel: TemplateChannel; + subject: string | null; + body: string; + variables: string[]; + metadata: Record; + status: TemplateStatus; + version: number; + created_by: string; + approved_by: string | null; + approved_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface TemplateVersion { + id: string; + template_id: string; + version: number; + subject: string | null; + body: string; + variables: string[]; + created_by: string; + created_at: Date; +} + +export interface TemplatePreview { + subject: string | null; + body: string; + variables_used: string[]; + missing_variables: string[]; +} + +// ─── Notification Template Service ─────────────────────────────────────────── + +export class NotificationTemplateService { + private readonly VARIABLE_PATTERN = /\{\{(\w+)\}\}/g; + + /** + * Create template + */ + async createTemplate( + template: Omit< + NotificationTemplate, + | "id" + | "status" + | "version" + | "approved_by" + | "approved_at" + | "created_at" + | "updated_at" + >, + ): Promise { + const db = getDatabase(); + + try { + const templateId = randomBytes(16).toString("hex"); + + // Extract variables from body + const variables = this.extractVariables(template.body); + if (template.subject) { + variables.push(...this.extractVariables(template.subject)); + } + + const newTemplate = { + id: templateId, + ...template, + variables: JSON.stringify([...new Set(variables)]), + metadata: JSON.stringify(template.metadata), + status: "draft" as TemplateStatus, + version: 1, + approved_by: null, + approved_at: null, + created_at: new Date(), + updated_at: new Date(), + }; + + await db("notification_templates").insert(newTemplate); + + // Create initial version + await this.createVersion( + templateId, + 1, + template.subject, + template.body, + variables, + template.created_by, + ); + + logger.info({ templateId, name: template.name }, "Template created"); + + return { + ...newTemplate, + variables: [...new Set(variables)], + metadata: template.metadata, + }; + } catch (error) { + logger.error({ error }, "Failed to create template"); + throw error; + } + } + + /** + * Get template + */ + async getTemplate(templateId: string): Promise { + const db = getDatabase(); + + try { + const template = await db("notification_templates") + .where({ id: templateId }) + .first(); + + if (!template) { + return null; + } + + return { + ...template, + variables: JSON.parse(template.variables || "[]"), + metadata: JSON.parse(template.metadata || "{}"), + }; + } catch (error) { + logger.error({ error, templateId }, "Failed to get template"); + return null; + } + } + + /** + * Update template + */ + async updateTemplate( + templateId: string, + updates: Partial< + Pick< + NotificationTemplate, + "name" | "description" | "subject" | "body" | "metadata" + > + >, + updatedBy: string, + ): Promise { + const db = getDatabase(); + + try { + const existing = await this.getTemplate(templateId); + if (!existing) { + return null; + } + + // Extract new variables if body or subject changed + let variables = existing.variables; + if (updates.body || updates.subject) { + const newVars: string[] = []; + if (updates.body) { + newVars.push(...this.extractVariables(updates.body)); + } + if (updates.subject) { + newVars.push(...this.extractVariables(updates.subject)); + } + variables = [...new Set([...existing.variables, ...newVars])]; + } + + const updateData: any = { + ...updates, + variables: JSON.stringify(variables), + version: existing.version + 1, + status: "draft", // Reset to draft on update + updated_at: new Date(), + }; + + if (updates.metadata) { + updateData.metadata = JSON.stringify(updates.metadata); + } + + await db("notification_templates") + .where({ id: templateId }) + .update(updateData); + + // Create new version + await this.createVersion( + templateId, + existing.version + 1, + updates.subject || existing.subject, + updates.body || existing.body, + variables, + updatedBy, + ); + + logger.info({ templateId }, "Template updated"); + + return await this.getTemplate(templateId); + } catch (error) { + logger.error({ error, templateId }, "Failed to update template"); + throw error; + } + } + + /** + * Submit template for approval + */ + async submitForApproval(templateId: string): Promise { + const db = getDatabase(); + + try { + await db("notification_templates").where({ id: templateId }).update({ + status: "pending_approval", + updated_at: new Date(), + }); + + logger.info({ templateId }, "Template submitted for approval"); + } catch (error) { + logger.error({ error, templateId }, "Failed to submit template"); + throw error; + } + } + + /** + * Approve template + */ + async approveTemplate(templateId: string, approvedBy: string): Promise { + const db = getDatabase(); + + try { + await db("notification_templates").where({ id: templateId }).update({ + status: "approved", + approved_by: approvedBy, + approved_at: new Date(), + updated_at: new Date(), + }); + + logger.info({ templateId, approvedBy }, "Template approved"); + } catch (error) { + logger.error({ error, templateId }, "Failed to approve template"); + throw error; + } + } + + /** + * Archive template + */ + async archiveTemplate(templateId: string): Promise { + const db = getDatabase(); + + try { + await db("notification_templates").where({ id: templateId }).update({ + status: "archived", + updated_at: new Date(), + }); + + logger.info({ templateId }, "Template archived"); + } catch (error) { + logger.error({ error, templateId }, "Failed to archive template"); + throw error; + } + } + + /** + * Preview template with variables + */ + async previewTemplate( + templateId: string, + variables: Record, + ): Promise { + try { + const template = await this.getTemplate(templateId); + if (!template) { + throw new Error("Template not found"); + } + + const renderedBody = this.renderTemplate(template.body, variables); + const renderedSubject = template.subject + ? this.renderTemplate(template.subject, variables) + : null; + + const variablesUsed = template.variables; + const missingVariables = variablesUsed.filter((v) => !(v in variables)); + + return { + subject: renderedSubject, + body: renderedBody, + variables_used: variablesUsed, + missing_variables: missingVariables, + }; + } catch (error) { + logger.error({ error, templateId }, "Failed to preview template"); + throw error; + } + } + + /** + * Validate template variables + */ + validateVariables( + templateBody: string, + templateSubject: string | null, + providedVariables: string[], + ): { valid: boolean; missing: string[]; unused: string[] } { + const requiredVars = this.extractVariables(templateBody); + if (templateSubject) { + requiredVars.push(...this.extractVariables(templateSubject)); + } + + const uniqueRequired = [...new Set(requiredVars)]; + const missing = uniqueRequired.filter( + (v) => !providedVariables.includes(v), + ); + const unused = providedVariables.filter((v) => !uniqueRequired.includes(v)); + + return { + valid: missing.length === 0, + missing, + unused, + }; + } + + /** + * Get all templates + */ + async getAllTemplates(filters?: { + channel?: TemplateChannel; + status?: TemplateStatus; + }): Promise { + const db = getDatabase(); + + try { + let query = db("notification_templates"); + + if (filters?.channel) { + query = query.where("channel", filters.channel); + } + if (filters?.status) { + query = query.where("status", filters.status); + } + + const templates = await query.orderBy("created_at", "desc"); + + return templates.map((t: any) => ({ + ...t, + variables: JSON.parse(t.variables || "[]"), + metadata: JSON.parse(t.metadata || "{}"), + })); + } catch (error) { + logger.error({ error }, "Failed to get all templates"); + return []; + } + } + + /** + * Get template versions + */ + async getTemplateVersions(templateId: string): Promise { + const db = getDatabase(); + + try { + const versions = await db("template_versions") + .where({ template_id: templateId }) + .orderBy("version", "desc"); + + return versions.map((v: any) => ({ + ...v, + variables: JSON.parse(v.variables || "[]"), + })); + } catch (error) { + logger.error({ error, templateId }, "Failed to get template versions"); + return []; + } + } + + /** + * Render template with variables + */ + private renderTemplate( + template: string, + variables: Record, + ): string { + return template.replace(this.VARIABLE_PATTERN, (match, varName) => { + return variables[varName] || match; + }); + } + + /** + * Extract variables from template + */ + private extractVariables(template: string): string[] { + const matches = template.matchAll(this.VARIABLE_PATTERN); + return Array.from(matches, (m) => m[1]); + } + + /** + * Create template version + */ + private async createVersion( + templateId: string, + version: number, + subject: string | null, + body: string, + variables: string[], + createdBy: string, + ): Promise { + const db = getDatabase(); + + try { + await db("template_versions").insert({ + id: randomBytes(16).toString("hex"), + template_id: templateId, + version, + subject, + body, + variables: JSON.stringify(variables), + created_by: createdBy, + created_at: new Date(), + }); + } catch (error) { + logger.error({ error, templateId, version }, "Failed to create version"); + } + } +} + +// ─── Singleton Instance ────────────────────────────────────────────────────── + +export const notificationTemplateService = new NotificationTemplateService(); diff --git a/frontend/src/components/SessionTimeoutModal.tsx b/frontend/src/components/SessionTimeoutModal.tsx new file mode 100644 index 00000000..6f68cdd4 --- /dev/null +++ b/frontend/src/components/SessionTimeoutModal.tsx @@ -0,0 +1,211 @@ +/** + * Session Timeout Modal + * Warns users before automatic logout with countdown timer + */ + +import React, { useEffect, useState, useCallback } from "react"; + +interface SessionTimeoutModalProps { + isOpen: boolean; + timeoutSeconds: number; + onExtendSession: () => void; + onLogout: () => void; +} + +export function SessionTimeoutModal({ + isOpen, + timeoutSeconds, + onExtendSession, + onLogout, +}: SessionTimeoutModalProps) { + const [secondsRemaining, setSecondsRemaining] = useState(timeoutSeconds); + + useEffect(() => { + if (!isOpen) { + setSecondsRemaining(timeoutSeconds); + return; + } + + const interval = setInterval(() => { + setSecondsRemaining((prev) => { + if (prev <= 1) { + clearInterval(interval); + onLogout(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(interval); + }, [isOpen, timeoutSeconds, onLogout]); + + const formatTime = useCallback((seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }, []); + + const handleExtend = useCallback(() => { + setSecondsRemaining(timeoutSeconds); + onExtendSession(); + }, [timeoutSeconds, onExtendSession]); + + if (!isOpen) return null; + + return ( +
+
+ {/* Icon */} +
+ +
+ + {/* Title */} +

+ Session Expiring Soon +

+ + {/* Description */} +

+ Your session will expire in{" "} + + {formatTime(secondsRemaining)} + + . Would you like to stay logged in? +

+ + {/* Progress Bar */} +
+
+
+ + {/* Actions */} +
+ + +
+ + {/* Keyboard hint */} +

+ Press{" "} + + Enter + {" "} + to stay logged in +

+
+
+ ); +} + +/** + * Hook to manage session timeout + */ +export function useSessionTimeout( + sessionDurationMs: number = 30 * 60 * 1000, // 30 minutes + warningBeforeMs: number = 2 * 60 * 1000, // 2 minutes warning +) { + const [showModal, setShowModal] = useState(false); + const [lastActivity, setLastActivity] = useState(Date.now()); + + const extendSession = useCallback(() => { + setLastActivity(Date.now()); + setShowModal(false); + }, []); + + const logout = useCallback(() => { + setShowModal(false); + // Implement actual logout logic + console.log("User logged out due to inactivity"); + // window.location.href = "/logout"; + }, []); + + useEffect(() => { + const handleActivity = () => { + setLastActivity(Date.now()); + setShowModal(false); + }; + + // Track user activity + const events = ["mousedown", "keydown", "scroll", "touchstart"]; + events.forEach((event) => { + window.addEventListener(event, handleActivity); + }); + + return () => { + events.forEach((event) => { + window.removeEventListener(event, handleActivity); + }); + }; + }, []); + + useEffect(() => { + const checkTimeout = setInterval(() => { + const timeSinceActivity = Date.now() - lastActivity; + const timeUntilTimeout = sessionDurationMs - timeSinceActivity; + + if (timeUntilTimeout <= 0) { + logout(); + } else if (timeUntilTimeout <= warningBeforeMs && !showModal) { + setShowModal(true); + } + }, 1000); + + return () => clearInterval(checkTimeout); + }, [lastActivity, sessionDurationMs, warningBeforeMs, showModal, logout]); + + return { + showModal, + timeoutSeconds: Math.floor(warningBeforeMs / 1000), + extendSession, + logout, + }; +}