diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md new file mode 100644 index 0000000..9a905ce --- /dev/null +++ b/LOCAL_DEVELOPMENT.md @@ -0,0 +1,398 @@ +# Local Development Setup Guide + +This guide walks you through setting up every component of NotifyChain on your local machine: the Soroban smart contracts (Rust), the off-chain listener service (Node.js/TypeScript), and the React dashboard. + +--- + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Repository Setup](#repository-setup) +3. [Smart Contracts](#smart-contracts) +4. [Listener Service](#listener-service) +5. [Dashboard](#dashboard) +6. [Running Everything Together](#running-everything-together) +7. [Environment Variables Reference](#environment-variables-reference) +8. [Example Configuration](#example-configuration) +9. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +### Required tools + +| Tool | Version | Install | +|------|---------|---------| +| Node.js | ≥ 18 | [nodejs.org](https://nodejs.org) | +| npm | ≥ 9 | Bundled with Node.js | +| Rust | stable | `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \| sh` | +| Stellar CLI | latest | `cargo install --locked stellar-cli --features opt` | + +### Verify installations + +```bash +node --version # v18+ +npm --version # 9+ +rustc --version +cargo --version +stellar --version +``` + +### WebAssembly target (required for contracts) + +```bash +rustup target add wasm32-unknown-unknown +``` + +--- + +## Repository Setup + +```bash +git clone https://github.com/Core-Foundry/Notify-Chain.git +cd Notify-Chain +``` + +--- + +## Smart Contracts + +### AutoShare contract + +```bash +cd contract +stellar contract build +``` + +Run tests: + +```bash +cd contracts/hello-world +cargo test +``` + +### TaskBounty contract + +```bash +cd "Documents/Task Bounty" +stellar contract build +# or: cargo build --target wasm32-unknown-unknown --release +``` + +Run tests: + +```bash +cargo test +``` + +### Deploying to testnet (optional) + +Generate and fund a test identity: + +```bash +stellar keys generate dev-account --network testnet +stellar keys fund dev-account --network testnet +``` + +Deploy: + +```bash +stellar contract deploy \ + --wasm target/wasm32-unknown-unknown/release/hello_world.wasm \ + --source dev-account \ + --network testnet +# Outputs: CONTRACT_ID +``` + +Initialize: + +```bash +stellar contract invoke \ + --id \ + --source dev-account \ + --network testnet \ + -- initialize_admin \ + --admin +``` + +--- + +## Listener Service + +The listener polls Stellar for contract events, persists them to SQLite, sends Discord notifications, and exposes an HTTP API. + +### Install dependencies + +```bash +cd listener +npm ci +``` + +### Configure environment + +```bash +cp .env.example .env +``` + +Edit `.env` — at minimum set: + +```env +STELLAR_RPC_URL=https://soroban-testnet.stellar.org:443 +CONTRACT_ADDRESSES=[{"address":"","events":["*"]}] +EVENTS_API_PORT=8787 +``` + +See [Environment Variables Reference](#environment-variables-reference) for all options. + +### Run in development mode + +```bash +npm run dev +``` + +### Build and run compiled output + +```bash +npm run build +npm start +``` + +### Run tests + +```bash +npm test +``` + +### Verify the service is running + +```bash +curl http://localhost:8787/health +``` + +Expected response: + +```json +{ "status": "ok", "timestamp": "...", "services": { ... } } +``` + +--- + +## Dashboard + +The dashboard is a React + Vite app that displays events fetched from the listener. + +### Install dependencies + +```bash +cd dashboard +npm ci +``` + +### Configure environment + +```bash +cp .env.example .env +``` + +The default `.env` points to the listener at `http://localhost:8787`: + +```env +VITE_EVENTS_API_URL=http://localhost:8787/api/events +VITE_STELLAR_NETWORK=TESTNET +``` + +### Run in development mode + +```bash +npm run dev +``` + +The dashboard is available at `http://localhost:5173`. + +### Build for production + +```bash +npm run build +npm run preview +``` + +### Run tests + +```bash +npm test +``` + +--- + +## Running Everything Together + +Open three terminal tabs: + +```bash +# Tab 1 — listener +cd listener && npm run dev + +# Tab 2 — dashboard +cd dashboard && npm run dev + +# Tab 3 — health check +curl http://localhost:8787/health +``` + +The dashboard at `http://localhost:5173` will start receiving events from the listener. + +--- + +## Environment Variables Reference + +### Listener (`listener/.env`) + +#### Network + +| Variable | Default | Description | +|----------|---------|-------------| +| `STELLAR_NETWORK` | `testnet` | Network name | +| `STELLAR_RPC_URL` | `https://soroban-testnet.stellar.org:443` | Stellar RPC endpoint | +| `STELLAR_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | Network passphrase | +| `CONTRACT_ADDRESSES` | — | JSON array of `{ address, events }` objects | + +#### Polling + +| Variable | Default | Description | +|----------|---------|-------------| +| `POLL_INTERVAL_MS` | `30000` | How often to poll for new events (ms) | +| `MAX_RECONNECT_ATTEMPTS` | `5` | Max reconnect attempts on failure | +| `RECONNECT_DELAY_MS` | `5000` | Delay between reconnect attempts (ms) | + +#### API + +| Variable | Default | Description | +|----------|---------|-------------| +| `EVENTS_API_PORT` | `8787` | Port for the HTTP events API | +| `EVENTS_API_CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin | +| `WEBHOOK_SECRETS` | `[]` | JSON array of `{ id, secret }` for webhook verification | + +#### Database + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_PATH` | `./data/notifications.db` | Path to the SQLite database file | + +#### Discord (optional) + +| Variable | Default | Description | +|----------|---------|-------------| +| `DISCORD_WEBHOOK_URL` | — | Discord webhook URL for notifications | + +#### Scheduler + +| Variable | Default | Description | +|----------|---------|-------------| +| `SCHEDULER_ENABLED` | `true` | Enable the scheduled notifications scheduler | +| `SCHEDULER_POLL_INTERVAL_MS` | `10000` | How often the scheduler checks for due notifications | +| `SCHEDULER_BATCH_SIZE` | `10` | Max notifications processed per cycle | + +#### Rate limiting + +| Variable | Default | Description | +|----------|---------|-------------| +| `RATE_LIMIT_ENABLED` | `true` | Enable rate limiting on the API | +| `RATE_LIMIT_WINDOW_MS` | `60000` | Time window for rate limiting (ms) | +| `RATE_LIMIT_MAX_REQUESTS` | `60` | Max requests per window per client | + +### Dashboard (`dashboard/.env`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `VITE_EVENTS_API_URL` | `http://localhost:8787/api/events` | Listener API endpoint | +| `VITE_STELLAR_NETWORK` | `TESTNET` | Stellar network (`TESTNET` or `PUBLIC`) | + +--- + +## Example Configuration + +Minimal `listener/.env` to monitor a testnet contract and receive Discord alerts: + +```env +STELLAR_NETWORK=testnet +STELLAR_RPC_URL=https://soroban-testnet.stellar.org:443 +STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + +CONTRACT_ADDRESSES=[{"address":"CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","events":["*"]}] + +EVENTS_API_PORT=8787 +EVENTS_API_CORS_ORIGIN=http://localhost:5173 + +DATABASE_PATH=./data/notifications.db + +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN + +SCHEDULER_ENABLED=true +RATE_LIMIT_ENABLED=true +``` + +Minimal `dashboard/.env`: + +```env +VITE_EVENTS_API_URL=http://localhost:8787/api/events +VITE_STELLAR_NETWORK=TESTNET +``` + +--- + +## Troubleshooting + +### Listener fails to start: `ConfigError` + +Check that `STELLAR_RPC_URL` and `CONTRACT_ADDRESSES` are set in `listener/.env`. The service exits on startup if required config is missing. + +### `DATABASE_PATH` directory does not exist + +Create the `data/` directory before starting the listener: + +```bash +mkdir -p listener/data +``` + +### No events appearing in the dashboard + +1. Confirm the listener is healthy: `curl http://localhost:8787/health` +2. Check `VITE_EVENTS_API_URL` in `dashboard/.env` matches the listener port. +3. Check `EVENTS_API_CORS_ORIGIN` in `listener/.env` matches the dashboard origin (`http://localhost:5173` by default). +4. Confirm `CONTRACT_ADDRESSES` contains the correct deployed contract ID. + +### Stellar RPC errors / timeouts + +- Switch to a different public RPC endpoint. The [Stellar Developer docs](https://developers.stellar.org/docs/tools/developer-tools/rpc-providers) list available providers. +- Increase `POLL_INTERVAL_MS` to reduce request frequency. + +### Contract build fails: `wasm32-unknown-unknown` not found + +```bash +rustup target add wasm32-unknown-unknown +``` + +### `cargo install stellar-cli` is slow or fails + +Try with the `--locked` flag to use pinned dependency versions: + +```bash +cargo install --locked stellar-cli --features opt +``` + +### Tests fail with SQLite errors + +The listener tests use an in-memory SQLite database (`:memory:`). Make sure `sqlite3` native bindings compiled correctly: + +```bash +cd listener +npm ci +npm test +``` + +If `sqlite3` fails to build, ensure you have a C++ toolchain installed (`build-essential` on Debian/Ubuntu, `xcode-select --install` on macOS). + +### Port already in use + +If port `8787` is taken, change `EVENTS_API_PORT` in `listener/.env` and update `VITE_EVENTS_API_URL` in `dashboard/.env` to match. diff --git a/listener/src/api/events-server.test.ts b/listener/src/api/events-server.test.ts index 93514ff..6bb44b8 100644 --- a/listener/src/api/events-server.test.ts +++ b/listener/src/api/events-server.test.ts @@ -11,6 +11,9 @@ import { Database, getDatabase, resetDatabaseSingleton } from '../database/datab jest.mock('@stellar/stellar-sdk', () => ({ rpc: { Server: jest.fn().mockImplementation(() => ({ + getHealth: mockGetHealth, + simulateTransaction: mockSimulateTransaction, + getAccount: jest.fn, []>().mockRejectedValue(new Error('not found')), getHealth: jest.fn(), simulateTransaction: jest.fn(), getAccount: jest.fn().mockRejectedValue(new Error('not found') as never), diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 13dfca1..dbf4354 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -28,6 +28,9 @@ import { TemplateNotFoundError, TemplateValidationError, } from '../services/notification-template-repository'; +import { + TemplateRenderError, +} from '../services/notification-template-service'; import { parseTemplateUpdateBody, resolveRequestActor, @@ -45,8 +48,8 @@ export interface EventsServerOptions { port: number; corsOrigin?: string; stellarRpcUrl: string; - stellarNetworkPassphrase: string; - contractAddresses: ContractConfig[]; + stellarNetworkPassphrase?: string; + contractAddresses?: ContractConfig[]; discordWebhookUrl?: string; webhookSecrets?: WebhookSecret[]; notificationAPI?: NotificationAPI | null; @@ -215,7 +218,7 @@ async function buildStatusResponse(options: EventsServerOptions): Promise<{ timestamp: string; }> { const contractStatuses = await Promise.all( - options.contractAddresses.map(async (contractConfig) => { + (options.contractAddresses ?? []).map(async (contractConfig) => { const status = await getContractPauseStatus(contractConfig.address, options.stellarRpcUrl); return { address: contractConfig.address, @@ -357,7 +360,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { const startTime = Date.now(); res.setHeader('Access-Control-Allow-Origin', corsOrigin); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, 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); @@ -831,6 +834,26 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // GET /api/templates + if (req.method === 'GET' && url.pathname === '/api/templates') { + if (!options.templateService) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template service not enabled' })); + return; + } + options.templateService.listAll() + .then((templates) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(templates.map(serializeTemplate))); + }) + .catch((error) => { + logger.error('Failed to list templates', { error, requestId, correlationId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + }); + return; + } + // GET /api/templates/:id/audit const templateAuditMatch = url.pathname.match(/^\/api\/templates\/([^/]+)\/audit$/); if (req.method === 'GET' && templateAuditMatch) { @@ -996,6 +1019,83 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // POST /api/templates/:id/render + const templateRenderMatch = url.pathname.match(/^\/api\/templates\/([^/]+)\/render$/); + if (req.method === 'POST' && templateRenderMatch) { + if (!options.templateService) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template service not enabled' })); + return; + } + + const templateId = decodeURIComponent(templateRenderMatch[1]); + logger.info('Handling POST /api/templates/:id/render', { requestId, correlationId, templateId }); + + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + void (async () => { + try { + const parsed = body ? JSON.parse(body) as Record : {}; + const template = await options.templateService!.getById(templateId); + if (!template) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Template not found: ${templateId}` })); + return; + } + const rendered = options.templateService!.renderTemplate(template, parsed); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(rendered)); + } catch (error) { + if (error instanceof SyntaxError) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (error instanceof TemplateRenderError) { + res.writeHead(422, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message })); + return; + } + logger.error('Failed to render template', { error, requestId, correlationId, templateId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + })(); + }); + return; + } + + // DELETE /api/templates/:id + const deleteTemplateMatch = url.pathname.match(/^\/api\/templates\/([^/]+)$/); + if (req.method === 'DELETE' && deleteTemplateMatch) { + if (!options.templateService) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Template service not enabled' })); + return; + } + + const templateId = decodeURIComponent(deleteTemplateMatch[1]); + logger.info('Handling DELETE /api/templates/:id', { requestId, correlationId, templateId }); + + options.templateService.delete(templateId) + .then(() => { + res.writeHead(204); + res.end(); + }) + .catch((error) => { + if (error instanceof TemplateNotFoundError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message })); + return; + } + logger.error('Failed to delete template', { error, requestId, correlationId, templateId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + }); + return; + } + // GET /api/preferences/:userId const getPrefsMatch = url.pathname.match(/^\/api\/preferences\/([^/]+)$/); if (req.method === 'GET' && getPrefsMatch) { diff --git a/listener/src/api/templates-api.test.ts b/listener/src/api/templates-api.test.ts index 4a31545..e78a941 100644 --- a/listener/src/api/templates-api.test.ts +++ b/listener/src/api/templates-api.test.ts @@ -189,6 +189,54 @@ describe('Template API endpoints', () => { expect(res.status).toBe(503); await new Promise((resolve, reject) => disabledServer.close((err) => (err ? reject(err) : resolve()))); }); + + it('GET /api/templates returns all templates', async () => { + await service.create({ id: 'tmpl-2', name: 'Second', type: 'sms', body: 'Hi {{name}}' }); + const res = await request(server, 'GET', '/api/templates'); + expect(res.status).toBe(200); + const body = res.body as Array<{ id: string }>; + expect(body.length).toBeGreaterThanOrEqual(2); + expect(body.some((t) => t.id === 'welcome-email')).toBe(true); + expect(body.some((t) => t.id === 'tmpl-2')).toBe(true); + }); + + it('DELETE /api/templates/:id removes the template', async () => { + const del = await request(server, 'DELETE', '/api/templates/welcome-email'); + expect(del.status).toBe(204); + + const get = await request(server, 'GET', '/api/templates/welcome-email'); + expect(get.status).toBe(404); + }); + + it('DELETE /api/templates/:id returns 404 for missing template', async () => { + const res = await request(server, 'DELETE', '/api/templates/missing'); + expect(res.status).toBe(404); + }); + + it('POST /api/templates/:id/render substitutes variables', async () => { + const res = await request(server, 'POST', '/api/templates/welcome-email/render', { + body: { name: 'Alice' }, + }); + expect(res.status).toBe(200); + const body = res.body as { body: string; subject: string }; + expect(body.body).toBe('Hello Alice'); + expect(body.subject).toBe('Welcome'); + }); + + it('POST /api/templates/:id/render returns 422 for missing variables', async () => { + const res = await request(server, 'POST', '/api/templates/welcome-email/render', { + body: {}, + }); + expect(res.status).toBe(422); + expect((res.body as { error: string }).error).toMatch(/missing required variables/i); + }); + + it('POST /api/templates/:id/render returns 404 for missing template', async () => { + const res = await request(server, 'POST', '/api/templates/missing/render', { + body: { name: 'Bob' }, + }); + expect(res.status).toBe(404); + }); }); describe('template-api helpers', () => { diff --git a/listener/src/database/schema.sql b/listener/src/database/schema.sql index 43584df..f0aac5a 100644 --- a/listener/src/database/schema.sql +++ b/listener/src/database/schema.sql @@ -50,6 +50,9 @@ CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_lock_expires ON scheduled_notifications(lock_expires_at, status) WHERE status = 'PROCESSING'; +-- Migration: add next_retry_at for explicit retry scheduling +ALTER TABLE scheduled_notifications ADD COLUMN next_retry_at DATETIME; + CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_next_retry_at ON scheduled_notifications(next_retry_at, status) WHERE status = 'PENDING'; diff --git a/listener/src/services/notification-template-repository.ts b/listener/src/services/notification-template-repository.ts index a258c06..814c8a9 100644 --- a/listener/src/services/notification-template-repository.ts +++ b/listener/src/services/notification-template-repository.ts @@ -149,6 +149,24 @@ export class NotificationTemplateRepository { return persisted; } + async listAll(): Promise { + const rows = await this.db.all( + 'SELECT * FROM notification_templates ORDER BY created_at DESC', + [], + ); + return rows.map((row) => this.rowToModel(row)); + } + + async delete(templateId: string): Promise { + const existing = await this.getById(templateId); + if (!existing) { + throw new TemplateNotFoundError(templateId); + } + await this.db.run('DELETE FROM notification_templates WHERE id = ?', [templateId]); + this.cache?.invalidate(templateId); + logger.info('Notification template deleted', { templateId }); + } + async getUpdateHistory(templateId: string): Promise { return this.auditTrail.getByTemplateId(templateId); } diff --git a/listener/src/services/notification-template-service.test.ts b/listener/src/services/notification-template-service.test.ts index 153e022..16dbc18 100644 --- a/listener/src/services/notification-template-service.test.ts +++ b/listener/src/services/notification-template-service.test.ts @@ -1,7 +1,7 @@ import { Database } from '../database/database'; import { NotificationTemplateCache } from './notification-template-cache'; import { NotificationTemplateRepository } from './notification-template-repository'; -import { NotificationTemplateService } from './notification-template-service'; +import { NotificationTemplateService, TemplateRenderError } from './notification-template-service'; import { TemplateAuditTrail } from './template-audit-trail'; describe('NotificationTemplateService', () => { @@ -43,4 +43,67 @@ describe('NotificationTemplateService', () => { expect(history).toHaveLength(1); expect(history[0].actor).toBe('service-admin'); }); + + it('listAll returns all created templates', async () => { + await service.create({ id: 'tmpl-a', name: 'A', type: 'email', body: 'Body A' }); + await service.create({ id: 'tmpl-b', name: 'B', type: 'sms', body: 'Body B' }); + + const all = await service.listAll(); + expect(all.length).toBeGreaterThanOrEqual(2); + expect(all.some((t) => t.id === 'tmpl-a')).toBe(true); + expect(all.some((t) => t.id === 'tmpl-b')).toBe(true); + }); + + it('delete removes a template and invalidates cache', async () => { + await service.create({ id: 'del-tmpl', name: 'Delete Me', type: 'email', body: 'Bye' }); + await service.getById('del-tmpl'); // populate cache + expect(cache.has('del-tmpl')).toBe(true); + + await service.delete('del-tmpl'); + + expect(cache.has('del-tmpl')).toBe(false); + expect(await service.getById('del-tmpl')).toBeUndefined(); + }); + + describe('renderTemplate', () => { + it('substitutes declared variables', async () => { + const template = await service.create({ + id: 'render-tmpl', + name: 'Render Test', + type: 'email', + subject: 'Hello {{name}}', + body: 'Dear {{name}}, your code is {{code}}.', + variables: ['name', 'code'], + }); + + const result = service.renderTemplate(template, { name: 'Alice', code: '123' }); + expect(result.subject).toBe('Hello Alice'); + expect(result.body).toBe('Dear Alice, your code is 123.'); + }); + + it('throws TemplateRenderError when a required variable is missing', async () => { + const template = await service.create({ + id: 'render-missing', + name: 'Missing Var', + type: 'email', + body: 'Hello {{name}}', + variables: ['name'], + }); + + expect(() => service.renderTemplate(template, {})).toThrow(TemplateRenderError); + expect(() => service.renderTemplate(template, {})).toThrow(/missing required variables/i); + }); + + it('renders a template with no declared variables', async () => { + const template = await service.create({ + id: 'no-vars', + name: 'No Vars', + type: 'email', + body: 'Static body with no placeholders', + }); + + const result = service.renderTemplate(template, {}); + expect(result.body).toBe('Static body with no placeholders'); + }); + }); }); diff --git a/listener/src/services/notification-template-service.ts b/listener/src/services/notification-template-service.ts index f90d8ea..013335a 100644 --- a/listener/src/services/notification-template-service.ts +++ b/listener/src/services/notification-template-service.ts @@ -7,6 +7,13 @@ import { import { NotificationTemplateRepository } from './notification-template-repository'; import { getTemplateCache, NotificationTemplateCache } from './notification-template-cache'; +export class TemplateRenderError extends Error { + constructor(message: string) { + super(message); + this.name = 'TemplateRenderError'; + } +} + /** * Application entry point for notification templates. * All reads go through cache; all writes go through the repository (with audit). @@ -23,6 +30,41 @@ export class NotificationTemplateService { return template; } + async listAll(): Promise { + return this.repository.listAll(); + } + + async delete(templateId: string): Promise { + await this.repository.delete(templateId); + this.cache.invalidate(templateId); + } + + /** + * Renders a template by substituting variables with provided values. + * Returns the rendered subject and body, or throws if required variables are missing. + */ + renderTemplate( + template: NotificationTemplate, + variables: Record, + ): { subject?: string; body: string } { + const declared = template.variables ?? []; + const missing = declared.filter((v) => !(v in variables)); + if (missing.length > 0) { + throw new TemplateRenderError(`Missing required variables: ${missing.join(', ')}`); + } + + const render = (text: string): string => + text.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + if (key in variables) return variables[key]; + throw new TemplateRenderError(`Unknown variable: ${key}`); + }); + + return { + ...(template.subject !== undefined ? { subject: render(template.subject) } : {}), + body: render(template.body), + }; + } + async getById(templateId: string): Promise { return this.cache.getOrLoad(templateId, () => this.repository.getById(templateId)); }