diff --git a/listener/package.json b/listener/package.json index 11ff0f8..1de3a88 100644 --- a/listener/package.json +++ b/listener/package.json @@ -10,6 +10,10 @@ "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "test": "node ./node_modules/jest/bin/jest.js", "migrate": "ts-node src/scripts/migrate-db.ts", + "migrate:templates": "ts-node src/scripts/migrate-templates.ts" + "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", + "lint": "node ./node_modules/typescript/bin/tsc --noEmit", + "migrate": "ts-node src/scripts/migrate-db.ts", "check-migrations": "ts-node src/scripts/check-migrations.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index e4ce703..358dd16 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -6,6 +6,9 @@ import { PreferencesUpdateInput } from '../types/preferences'; import { NotificationAPI } from '../services/notification-api'; import { NotificationType } from '../types/scheduled-notification'; import logger from '../utils/logger'; +import { generateRequestId } from '../utils/request-id'; +import { TemplateService } from '../services/template-service'; +import { handleTemplateRoutes } from './template-routes'; import { generateRequestId, resolveCorrelationId } from '../utils/request-id'; import { NotificationHistoryService } from '../services/notification-history'; import { SearchSuggestionService } from '../services/search-suggestion'; @@ -55,6 +58,7 @@ export interface EventsServerOptions { webhookSecrets?: WebhookSecret[]; apiKeys?: Array<{ key: string; name?: string }>; notificationAPI?: NotificationAPI | null; + templateService?: TemplateService | null; rateLimit?: RateLimitConfig; /** * Optional override for the analytics aggregator. Tests use this to inject @@ -376,6 +380,8 @@ export function createEventsServer(options: EventsServerOptions): http.Server { res.setHeader('Access-Control-Allow-Origin', corsOrigin); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, Authorization, X-Correlation-Id'); res.setHeader('X-Request-Id', requestId); res.setHeader('X-Correlation-Id', correlationId); @@ -399,6 +405,24 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // Template API routes (handled first for priority) + if (options.templateService && req.url?.startsWith('/api/templates')) { + handleTemplateRoutes(req, res, requestId, options.templateService) + .then((handled) => { + if (!handled) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + }) + .catch((error) => { + logger.error('Template route handler error', { error, requestId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + }); + return; + } + + if (req.method === 'GET' && req.url === '/health') { // GET /health if (req.method === 'GET' && url.pathname === '/health') { buildHealthResponse(options).then((health) => { diff --git a/listener/src/database/schema.sql b/listener/src/database/schema.sql index 678b1ef..7105f4b 100644 --- a/listener/src/database/schema.sql +++ b/listener/src/database/schema.sql @@ -99,6 +99,79 @@ BEGIN WHERE id = NEW.id; END; +-- =============================================== +-- NOTIFICATION TEMPLATE SYSTEM SCHEMA +-- =============================================== + +-- Main table for notification templates +CREATE TABLE IF NOT EXISTS notification_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Template identification + unique_key VARCHAR(100) NOT NULL UNIQUE, -- e.g., 'welcome_email', 'payment_confirmation' + name VARCHAR(255) NOT NULL, -- Human-readable name + description TEXT, -- Template purpose/usage description + + -- Template content + channel_type VARCHAR(50) NOT NULL, -- EMAIL, SMS, DISCORD, PUSH, WEBHOOK + subject_template TEXT, -- Optional subject (for EMAIL, PUSH) + body_template TEXT NOT NULL, -- Main template content with {{placeholders}} + + -- Variable definitions + variables TEXT NOT NULL, -- JSON array of required variable names + default_values TEXT, -- JSON object with default values for optional variables + + -- Metadata + is_active BOOLEAN NOT NULL DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1, -- Template versioning for A/B testing + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), -- User/system that created template + + -- Validation + last_validated_at DATETIME, + validation_status VARCHAR(20) DEFAULT 'PENDING' -- VALID, INVALID, PENDING +); + +-- Indexes for template lookups +CREATE INDEX IF NOT EXISTS idx_templates_unique_key + ON notification_templates(unique_key); + +CREATE INDEX IF NOT EXISTS idx_templates_channel_type + ON notification_templates(channel_type, is_active) + WHERE is_active = 1; + +CREATE INDEX IF NOT EXISTS idx_templates_active + ON notification_templates(is_active, created_at); + +-- Template usage tracking for analytics +CREATE TABLE IF NOT EXISTS template_usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + rendered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + context_hash VARCHAR(64), -- Hash of the context data for deduplication + success BOOLEAN NOT NULL DEFAULT 1, + error_message TEXT, + render_duration_ms INTEGER, + + FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_template_usage_template_id + ON template_usage_log(template_id, rendered_at); + +CREATE INDEX IF NOT EXISTS idx_template_usage_rendered_at + ON template_usage_log(rendered_at); + +-- Trigger to update template updated_at timestamp +CREATE TRIGGER IF NOT EXISTS update_notification_templates_timestamp +AFTER UPDATE ON notification_templates +FOR EACH ROW +BEGIN + UPDATE notification_templates + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; +END; -- Rate limit events table for auditing CREATE TABLE IF NOT EXISTS rate_limit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/listener/src/index.ts b/listener/src/index.ts index c6e78fe..6a30b8e 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -37,10 +37,17 @@ dotenv.config(); async function main() { const config = loadConfig(); + // Initialize database for scheduled notifications and templates // Initialize database for templates, scheduler, and rate limiting let scheduler: NotificationScheduler | null = null; let retryScheduler: RetryScheduler | null = null; let notificationAPI: NotificationAPI | null = null; + let templateService: TemplateService | null = null; + + if (config.scheduler?.enabled) { + try { + logger.info('Initializing database for scheduled notifications and templates'); + const db = await initializeDatabase(config.databasePath); let templateService: NotificationTemplateService | null = null; let cleanupService: CleanupService | null = null; let reconciliationEngine: IndexingReconciliationEngine | null = null; @@ -147,6 +154,8 @@ async function main() { stellarNetworkPassphrase: config.stellarNetworkPassphrase, contractAddresses: config.contractAddresses, discordWebhookUrl: config.discord?.webhookUrl, + notificationAPI, // Pass API to events server for scheduling endpoints + templateService, // Pass template service for template endpoints webhookSecrets: config.webhookSecrets, apiKeys: config.apiKeys, notificationAPI,