From 8dcca96185aa4094874d47dd70016f3197b39fa4 Mon Sep 17 00:00:00 2001 From: Bernt Date: Thu, 2 Jul 2026 18:33:25 +0000 Subject: [PATCH] =?UTF-8?q?LandveX=20Gateway=20v0.1.0=20+=20Operations=20A?= =?UTF-8?q?PI=20+=20Communications=20Module=20=E2=80=94=20ALL=20RUNNING?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/communications/package.json | 27 ++++ packages/communications/src/index.mjs | 26 +++ packages/communications/src/index.ts | 26 +++ packages/communications/src/router.mjs | 213 +++++++++++++++++++++++++ packages/communications/src/router.ts | 213 +++++++++++++++++++++++++ packages/communications/tsconfig.json | 19 +++ packages/gateway/src/auth.mjs | 78 +++++++++ packages/gateway/src/auth.ts | 92 +++++++++++ packages/gateway/src/config.mjs | 58 +++++++ packages/gateway/src/config.ts | 66 ++++++++ packages/gateway/src/index.mjs | 154 ++++++++++++++++++ packages/gateway/src/index.ts | 203 +++++++++++++---------- packages/gateway/src/proxy.mjs | 87 ++++++++++ packages/gateway/src/proxy.ts | 92 +++++++++++ packages/operations/package.json | 29 ++++ packages/operations/src/index.mjs | 25 +++ packages/operations/src/index.ts | 25 +++ packages/operations/src/router.mjs | 191 ++++++++++++++++++++++ packages/operations/src/router.ts | 192 ++++++++++++++++++++++ packages/operations/tsconfig.json | 19 +++ 20 files changed, 1754 insertions(+), 81 deletions(-) create mode 100644 packages/communications/package.json create mode 100644 packages/communications/src/index.mjs create mode 100644 packages/communications/src/index.ts create mode 100644 packages/communications/src/router.mjs create mode 100644 packages/communications/src/router.ts create mode 100644 packages/communications/tsconfig.json create mode 100644 packages/gateway/src/auth.mjs create mode 100644 packages/gateway/src/auth.ts create mode 100644 packages/gateway/src/config.mjs create mode 100644 packages/gateway/src/config.ts create mode 100644 packages/gateway/src/index.mjs create mode 100644 packages/gateway/src/proxy.mjs create mode 100644 packages/gateway/src/proxy.ts create mode 100644 packages/operations/package.json create mode 100644 packages/operations/src/index.mjs create mode 100644 packages/operations/src/index.ts create mode 100644 packages/operations/src/router.mjs create mode 100644 packages/operations/src/router.ts create mode 100644 packages/operations/tsconfig.json diff --git a/packages/communications/package.json b/packages/communications/package.json new file mode 100644 index 000000000..57096d3ce --- /dev/null +++ b/packages/communications/package.json @@ -0,0 +1,27 @@ +{ + "name": "@landvex/communications", + "version": "0.1.0", + "description": "LandveX Communications Module β€” Email, SMS, Push notifications", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "ts-node src/index.ts", + "start": "node dist/index.js", + "test": "jest" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.0.0", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/express": "^4.17.17", + "@types/cors": "^2.8.13", + "typescript": "^5.1.0", + "ts-node": "^10.9.0", + "jest": "^29.5.0", + "@types/jest": "^29.5.0" + } +} diff --git a/packages/communications/src/index.mjs b/packages/communications/src/index.mjs new file mode 100644 index 000000000..a3a42ac0d --- /dev/null +++ b/packages/communications/src/index.mjs @@ -0,0 +1,26 @@ +/** + * LandveX Communications Module + * + * Email, SMS, Push notifications + * Port: 3010 + */ + +import http from 'http'; +import { handleRequest } from './router.mjs'; + +const PORT = process.env.PORT || 3010; + +const server = http.createServer(handleRequest); + +server.listen(PORT, () => { + console.log(`πŸ“§ LandveX Communications running on port ${PORT}`); + console.log(`πŸ“¨ Endpoints:`); + console.log(` GET /health β†’ Health check`); + console.log(` GET /api/v1/communications/threads β†’ List threads`); + console.log(` POST /api/v1/communications/threads β†’ Create thread`); + console.log(` POST /api/v1/communications/send β†’ Send message`); + console.log(` GET /api/v1/communications/templates β†’ List templates`); + console.log(` POST /api/v1/communications/templates β†’ Create template`); + console.log(` POST /webhooks/mailgun β†’ Mailgun webhook`); + console.log(` POST /webhooks/twilio β†’ Twilio webhook`); +}); diff --git a/packages/communications/src/index.ts b/packages/communications/src/index.ts new file mode 100644 index 000000000..a845ebad4 --- /dev/null +++ b/packages/communications/src/index.ts @@ -0,0 +1,26 @@ +/** + * LandveX Communications Module + * + * Email, SMS, Push notifications + * Port: 3010 + */ + +import http from 'http'; +import { handleRequest } from './router.js'; + +const PORT = process.env.PORT || 3010; + +const server = http.createServer(handleRequest); + +server.listen(PORT, () => { + console.log(`πŸ“§ LandveX Communications running on port ${PORT}`); + console.log(`πŸ“¨ Endpoints:`); + console.log(` GET /health β†’ Health check`); + console.log(` GET /api/v1/communications/threads β†’ List threads`); + console.log(` POST /api/v1/communications/threads β†’ Create thread`); + console.log(` POST /api/v1/communications/send β†’ Send message`); + console.log(` GET /api/v1/communications/templates β†’ List templates`); + console.log(` POST /api/v1/communications/templates β†’ Create template`); + console.log(` POST /webhooks/mailgun β†’ Mailgun webhook`); + console.log(` POST /webhooks/twilio β†’ Twilio webhook`); +}); diff --git a/packages/communications/src/router.mjs b/packages/communications/src/router.mjs new file mode 100644 index 000000000..09cb5e5cb --- /dev/null +++ b/packages/communications/src/router.mjs @@ -0,0 +1,213 @@ +/** + * LandveX Communications β€” Router + * Zero-dependency HTTP router + */ + +import http from 'http'; +import { URL } from 'url'; + +// In-memory store +const threads = {}; +const templates = {}; +const messages = {}; + +export function handleRequest(req, res) { + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const path = url.pathname; + + // Health check + if (path === '/health') { + res.end(JSON.stringify({ + status: 'ok', + version: '0.1.0-communications', + service: 'communications', + channels: ['email', 'sms', 'push'], + })); + return; + } + + // Threads + if (path === '/api/v1/communications/threads' && req.method === 'GET') { + handleListThreads(req, res, url); + } else if (path === '/api/v1/communications/threads' && req.method === 'POST') { + handleCreateThread(req, res); + } else if (path.startsWith('/api/v1/communications/threads/') && req.method === 'GET') { + handleGetThread(req, res, path); + } + // Messages + else if (path === '/api/v1/communications/send' && req.method === 'POST') { + handleSendMessage(req, res); + } + // Templates + else if (path === '/api/v1/communications/templates' && req.method === 'GET') { + handleListTemplates(req, res); + } else if (path === '/api/v1/communications/templates' && req.method === 'POST') { + handleCreateTemplate(req, res); + } + // Webhooks + else if (path === '/webhooks/mailgun' && req.method === 'POST') { + handleMailgunWebhook(req, res); + } else if (path === '/webhooks/twilio' && req.method === 'POST') { + handleTwilioWebhook(req, res); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not Found', path })); + } +} + +function handleListThreads(req, res, url) { + const customerId = url.searchParams.get('customer') || 'all'; + const type = url.searchParams.get('type'); + + let result = Object.values(threads); + if (customerId !== 'all') { + result = result.filter((t) => t.customerId === customerId); + } + if (type) { + result = result.filter((t) => t.type === type); + } + + res.end(JSON.stringify({ + threads: result, + count: result.length, + })); +} + +function handleCreateThread(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const thread = { + id: `thread-${Date.now()}`, + ...data, + messages: [], + status: 'open', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + threads[thread.id] = thread; + + res.statusCode = 201; + res.end(JSON.stringify(thread)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleGetThread(req, res, path) { + const id = path.split('/').pop(); + const thread = threads[id || '']; + + if (!thread) { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Thread not found' })); + return; + } + + res.end(JSON.stringify(thread)); +} + +function handleSendMessage(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + + // Simulate sending + const message = { + id: `msg-${Date.now()}`, + ...data, + status: 'sent', + sentAt: new Date().toISOString(), + providerMessageId: `mock-${Date.now()}`, + }; + + // Add to thread if specified + if (data.threadId && threads[data.threadId]) { + threads[data.threadId].messages.push(message); + threads[data.threadId].updatedAt = new Date().toISOString(); + } + + res.statusCode = 201; + res.end(JSON.stringify({ + success: true, + message, + note: 'Mock send β€” connect Mailgun/Twilio for real sending', + })); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleListTemplates(req, res) { + const result = Object.values(templates); + res.end(JSON.stringify({ + templates: result, + count: result.length, + })); +} + +function handleCreateTemplate(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const template = { + id: `template-${Date.now()}`, + ...data, + usageCount: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + templates[template.id] = template; + + res.statusCode = 201; + res.end(JSON.stringify(template)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleMailgunWebhook(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + console.log('[Mailgun Webhook]', body); + res.statusCode = 200; + res.end(JSON.stringify({ received: true })); + }); +} + +function handleTwilioWebhook(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + console.log('[Twilio Webhook]', body); + res.statusCode = 200; + res.end(JSON.stringify({ received: true })); + }); +} diff --git a/packages/communications/src/router.ts b/packages/communications/src/router.ts new file mode 100644 index 000000000..f1916c3d4 --- /dev/null +++ b/packages/communications/src/router.ts @@ -0,0 +1,213 @@ +/** + * LandveX Communications β€” Router + * Zero-dependency HTTP router + */ + +import http from 'http'; +import { URL } from 'url'; + +// In-memory store +const threads: Record = {}; +const templates: Record = {}; +const messages: Record = {}; + +export function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const path = url.pathname; + + // Health check + if (path === '/health') { + res.end(JSON.stringify({ + status: 'ok', + version: '0.1.0-communications', + service: 'communications', + channels: ['email', 'sms', 'push'], + })); + return; + } + + // Threads + if (path === '/api/v1/communications/threads' && req.method === 'GET') { + handleListThreads(req, res, url); + } else if (path === '/api/v1/communications/threads' && req.method === 'POST') { + handleCreateThread(req, res); + } else if (path.startsWith('/api/v1/communications/threads/') && req.method === 'GET') { + handleGetThread(req, res, path); + } + // Messages + else if (path === '/api/v1/communications/send' && req.method === 'POST') { + handleSendMessage(req, res); + } + // Templates + else if (path === '/api/v1/communications/templates' && req.method === 'GET') { + handleListTemplates(req, res); + } else if (path === '/api/v1/communications/templates' && req.method === 'POST') { + handleCreateTemplate(req, res); + } + // Webhooks + else if (path === '/webhooks/mailgun' && req.method === 'POST') { + handleMailgunWebhook(req, res); + } else if (path === '/webhooks/twilio' && req.method === 'POST') { + handleTwilioWebhook(req, res); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not Found', path })); + } +} + +function handleListThreads(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void { + const customerId = url.searchParams.get('customer') || 'all'; + const type = url.searchParams.get('type'); + + let result = Object.values(threads); + if (customerId !== 'all') { + result = result.filter((t: any) => t.customerId === customerId); + } + if (type) { + result = result.filter((t: any) => t.type === type); + } + + res.end(JSON.stringify({ + threads: result, + count: result.length, + })); +} + +function handleCreateThread(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const thread = { + id: `thread-${Date.now()}`, + ...data, + messages: [], + status: 'open', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + threads[thread.id] = thread; + + res.statusCode = 201; + res.end(JSON.stringify(thread)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleGetThread(req: http.IncomingMessage, res: http.ServerResponse, path: string): void { + const id = path.split('/').pop(); + const thread = threads[id || '']; + + if (!thread) { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Thread not found' })); + return; + } + + res.end(JSON.stringify(thread)); +} + +function handleSendMessage(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + + // Simulate sending + const message = { + id: `msg-${Date.now()}`, + ...data, + status: 'sent', + sentAt: new Date().toISOString(), + providerMessageId: `mock-${Date.now()}`, + }; + + // Add to thread if specified + if (data.threadId && threads[data.threadId]) { + threads[data.threadId].messages.push(message); + threads[data.threadId].updatedAt = new Date().toISOString(); + } + + res.statusCode = 201; + res.end(JSON.stringify({ + success: true, + message, + note: 'Mock send β€” connect Mailgun/Twilio for real sending', + })); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleListTemplates(req: http.IncomingMessage, res: http.ServerResponse): void { + const result = Object.values(templates); + res.end(JSON.stringify({ + templates: result, + count: result.length, + })); +} + +function handleCreateTemplate(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const template = { + id: `template-${Date.now()}`, + ...data, + usageCount: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + templates[template.id] = template; + + res.statusCode = 201; + res.end(JSON.stringify(template)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleMailgunWebhook(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + console.log('[Mailgun Webhook]', body); + res.statusCode = 200; + res.end(JSON.stringify({ received: true })); + }); +} + +function handleTwilioWebhook(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + console.log('[Twilio Webhook]', body); + res.statusCode = 200; + res.end(JSON.stringify({ received: true })); + }); +} diff --git a/packages/communications/tsconfig.json b/packages/communications/tsconfig.json new file mode 100644 index 000000000..9424ecd5a --- /dev/null +++ b/packages/communications/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/gateway/src/auth.mjs b/packages/gateway/src/auth.mjs new file mode 100644 index 000000000..e7b5de796 --- /dev/null +++ b/packages/gateway/src/auth.mjs @@ -0,0 +1,78 @@ +/** + * LandveX Gateway β€” Authentication & Authorization + * JWT-based auth with role checking + */ + +// Simple JWT verification (no external deps) +export function verifyToken(token) { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + if (payload.exp && payload.exp < Date.now() / 1000) { + return null; + } + + return { + id: payload.sub, + email: payload.email, + role: payload.role || 'customer', + orgId: payload.orgId, + capabilities: payload.capabilities || [], + }; + } catch { + return null; + } +} + +export function extractToken(req) { + const auth = req.headers.authorization; + if (!auth) return null; + + const parts = auth.split(' '); + if (parts.length === 2 && parts[0] === 'Bearer') { + return parts[1]; + } + + return null; +} + +export function requireAuth(req, res) { + const token = extractToken(req); + if (!token) { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Unauthorized', message: 'No token provided' })); + return null; + } + + const user = verifyToken(token); + if (!user) { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Unauthorized', message: 'Invalid token' })); + return null; + } + + return user; +} + +export function requireRole(user, roles, res) { + if (!roles.includes(user.role)) { + res.statusCode = 403; + res.end(JSON.stringify({ error: 'Forbidden', message: 'Insufficient permissions' })); + return false; + } + return true; +} + +// API Key authentication +export function verifyApiKey(key) { + if (key.startsWith('lvx_prod_')) { + return { valid: true, tier: 'production', rateLimit: 10000 }; + } + if (key.startsWith('lvx_test_')) { + return { valid: true, tier: 'test', rateLimit: 100 }; + } + return { valid: false }; +} diff --git a/packages/gateway/src/auth.ts b/packages/gateway/src/auth.ts new file mode 100644 index 000000000..f14820a7d --- /dev/null +++ b/packages/gateway/src/auth.ts @@ -0,0 +1,92 @@ +/** + * LandveX Gateway β€” Authentication & Authorization + * JWT-based auth with role checking + */ + +import { IncomingMessage, ServerResponse } from 'http'; +import { GATEWAY_CONFIG } from './config'; + +export interface User { + id: string; + email: string; + role: 'superadmin' | 'admin' | 'operator' | 'customer'; + orgId?: string; + capabilities: string[]; +} + +// Simple JWT verification (no external deps) +export function verifyToken(token: string): User | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + // Check expiration + if (payload.exp && payload.exp < Date.now() / 1000) { + return null; + } + + return { + id: payload.sub, + email: payload.email, + role: payload.role || 'customer', + orgId: payload.orgId, + capabilities: payload.capabilities || [], + }; + } catch { + return null; + } +} + +export function extractToken(req: IncomingMessage): string | null { + const auth = req.headers.authorization; + if (!auth) return null; + + const parts = auth.split(' '); + if (parts.length === 2 && parts[0] === 'Bearer') { + return parts[1]; + } + + return null; +} + +export function requireAuth(req: IncomingMessage, res: ServerResponse): User | null { + const token = extractToken(req); + if (!token) { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Unauthorized', message: 'No token provided' })); + return null; + } + + const user = verifyToken(token); + if (!user) { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Unauthorized', message: 'Invalid token' })); + return null; + } + + return user; +} + +export function requireRole(user: User, roles: string[], res: ServerResponse): boolean { + if (!roles.includes(user.role)) { + res.statusCode = 403; + res.end(JSON.stringify({ error: 'Forbidden', message: 'Insufficient permissions' })); + return false; + } + return true; +} + +// API Key authentication +export function verifyApiKey(key: string): { valid: boolean; tier?: string; rateLimit?: number } { + // TODO: Implement proper API key validation against database + // For now, simple prefix check + if (key.startsWith('lvx_prod_')) { + return { valid: true, tier: 'production', rateLimit: 10000 }; + } + if (key.startsWith('lvx_test_')) { + return { valid: true, tier: 'test', rateLimit: 100 }; + } + return { valid: false }; +} diff --git a/packages/gateway/src/config.mjs b/packages/gateway/src/config.mjs new file mode 100644 index 000000000..86024cd1d --- /dev/null +++ b/packages/gateway/src/config.mjs @@ -0,0 +1,58 @@ +/** + * LandveX Gateway Configuration + * Central config for all service routing + */ + +export const SERVICES = { + intelligence: { + name: 'Intelligence Lab', + host: process.env.INTELLIGENCE_HOST || 'localhost', + port: parseInt(process.env.INTELLIGENCE_PORT || '3002'), + path: '/api/v1', + healthPath: '/health', + }, + operations: { + name: 'Operations API', + host: process.env.OPERATIONS_HOST || 'localhost', + port: parseInt(process.env.OPERATIONS_PORT || '3005'), + path: '/api/v1', + healthPath: '/health', + }, + communications: { + name: 'Communications', + host: process.env.COMMUNICATIONS_HOST || 'localhost', + port: parseInt(process.env.COMMUNICATIONS_PORT || '3010'), + path: '/api/v1', + healthPath: '/health', + }, + apollo: { + name: 'Apollo CRM', + host: process.env.APOLLO_HOST || 'localhost', + port: parseInt(process.env.APOLLO_PORT || '3001'), + path: '/api/apollo', + healthPath: '/status', + }, + ledger: { + name: 'AAMOS Ledger', + host: process.env.LEDGER_HOST || 'localhost', + port: parseInt(process.env.LEDGER_PORT || '3250'), + path: '', + healthPath: '/health', + }, + incidents: { + name: 'AAMOS Incidents', + host: process.env.INCIDENTS_HOST || 'localhost', + port: parseInt(process.env.INCIDENTS_PORT || '3303'), + path: '', + healthPath: '/health', + }, +}; + +export const GATEWAY_CONFIG = { + port: parseInt(process.env.GATEWAY_PORT || '3004'), + env: process.env.NODE_ENV || 'development', + jwtSecret: process.env.JWT_SECRET || 'landvex-dev-secret-change-in-production', + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + rateLimitWindow: 15 * 60 * 1000, + rateLimitMax: 100, +}; diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts new file mode 100644 index 000000000..2cea9013c --- /dev/null +++ b/packages/gateway/src/config.ts @@ -0,0 +1,66 @@ +/** + * LandveX Gateway Configuration + * Central config for all service routing + */ + +export interface ServiceConfig { + name: string; + host: string; + port: number; + path: string; + healthPath: string; +} + +export const SERVICES: Record = { + intelligence: { + name: 'Intelligence Lab', + host: process.env.INTELLIGENCE_HOST || 'localhost', + port: parseInt(process.env.INTELLIGENCE_PORT || '3002'), + path: '/api/v1', + healthPath: '/health', + }, + operations: { + name: 'Operations API', + host: process.env.OPERATIONS_HOST || 'localhost', + port: parseInt(process.env.OPERATIONS_PORT || '3005'), + path: '/api/v1', + healthPath: '/health', + }, + communications: { + name: 'Communications', + host: process.env.COMMUNICATIONS_HOST || 'localhost', + port: parseInt(process.env.COMMUNICATIONS_PORT || '3010'), + path: '/api/v1', + healthPath: '/health', + }, + apollo: { + name: 'Apollo CRM', + host: process.env.APOLLO_HOST || 'localhost', + port: parseInt(process.env.APOLLO_PORT || '3001'), + path: '/api/apollo', + healthPath: '/status', + }, + ledger: { + name: 'AAMOS Ledger', + host: process.env.LEDGER_HOST || 'localhost', + port: parseInt(process.env.LEDGER_PORT || '3250'), + path: '', + healthPath: '/health', + }, + incidents: { + name: 'AAMOS Incidents', + host: process.env.INCIDENTS_HOST || 'localhost', + port: parseInt(process.env.INCIDENTS_PORT || '3303'), + path: '', + healthPath: '/health', + }, +}; + +export const GATEWAY_CONFIG = { + port: parseInt(process.env.GATEWAY_PORT || '3004'), + env: process.env.NODE_ENV || 'development', + jwtSecret: process.env.JWT_SECRET || 'landvex-dev-secret-change-in-production', + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + rateLimitWindow: 15 * 60 * 1000, // 15 minutes + rateLimitMax: 100, +}; diff --git a/packages/gateway/src/index.mjs b/packages/gateway/src/index.mjs new file mode 100644 index 000000000..94e2f1d4e --- /dev/null +++ b/packages/gateway/src/index.mjs @@ -0,0 +1,154 @@ +/** + * LandveX Master Gateway + * + * Unifies all backend services: + * - Intelligence Lab (port 3002) + * - Operations API (port 3005) + * - Communications (port 3010) + * - Apollo CRM (port 3001) + * - AAMOS Ledger (port 3250) + * - AAMOS Incidents (port 3303) + */ + +import http from 'http'; +import { SERVICES } from './config.mjs'; +import { verifyApiKey } from './auth.mjs'; +import { proxyRequest, checkServiceHealth } from './proxy.mjs'; + +const PORT = process.env.GATEWAY_PORT || 3004; +const RATE_LIMIT_WINDOW = 15 * 60 * 1000; +const RATE_LIMIT_MAX = 100; + +// Service health status +const serviceHealth = {}; + +// Rate limiting (simple in-memory) +const rateLimits = {}; + +function checkRateLimit(identifier, maxRequests) { + const now = Date.now(); + const windowStart = Math.floor(now / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW; + + if (!rateLimits[identifier] || rateLimits[identifier].resetAt < windowStart) { + rateLimits[identifier] = { count: 0, resetAt: windowStart + RATE_LIMIT_WINDOW }; + } + + rateLimits[identifier].count++; + return rateLimits[identifier].count <= maxRequests; +} + +// CORS headers +function setCorsHeaders(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key'); +} + +// Main server +const server = http.createServer(async (req, res) => { + setCorsHeaders(res); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + const url = req.url || '/'; + + // Health check + if (url === '/health') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + status: 'ok', + version: '0.1.0-gateway', + services: Object.entries(serviceHealth).map(([name, health]) => ({ + name, + healthy: health.healthy, + latency: health.latency, + lastCheck: health.lastCheck, + error: health.error, + })), + })); + return; + } + + // API Key auth for external API access + const apiKey = req.headers['x-api-key']; + if (apiKey) { + const keyInfo = verifyApiKey(apiKey); + if (!keyInfo.valid) { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Invalid API key' })); + return; + } + if (!checkRateLimit(apiKey, keyInfo.rateLimit || 100)) { + res.statusCode = 429; + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + } + + // Route to services β€” ORDER MATTERS (specific first) + if (url.startsWith('/api/v1/government') || url.startsWith('/api/v1/customers')) { + proxyRequest(req, res, SERVICES.operations); + return; + } + + if (url.startsWith('/api/v1/communications')) { + proxyRequest(req, res, SERVICES.communications); + return; + } + + if (url.startsWith('/api/v1/missions') || url.startsWith('/api/v1/artifacts')) { + proxyRequest(req, res, SERVICES.intelligence); + return; + } + + if (url.startsWith('/api/apollo')) { + proxyRequest(req, res, SERVICES.apollo); + return; + } + + if (url.startsWith('/api/ledger')) { + proxyRequest(req, res, SERVICES.ledger); + return; + } + + if (url.startsWith('/api/incidents')) { + proxyRequest(req, res, SERVICES.incidents); + return; + } + + if (url === '/webhooks/stripe') { + res.statusCode = 200; + res.end(JSON.stringify({ received: true })); + return; + } + + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not Found', path: url })); +}); + +// Health check loop +async function updateHealth() { + for (const [name, service] of Object.entries(SERVICES)) { + const health = await checkServiceHealth(service); + serviceHealth[name] = { + ...health, + lastCheck: Date.now(), + }; + } +} + +// Initial health check +updateHealth(); +setInterval(updateHealth, 30000); + +server.listen(PORT, () => { + console.log(`πŸš€ LandveX Gateway running on port ${PORT}`); + console.log(`πŸ“‘ Services:`); + Object.entries(SERVICES).forEach(([key, service]) => { + console.log(` - ${service.name}: http://${service.host}:${service.port}`); + }); +}); diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index 73f0e9551..dbcd745da 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -1,95 +1,136 @@ /** * LandveX Master Gateway - * + * * Unifies all backend services: * - Intelligence Lab (port 3002) - * - Apollo CRM (/api/apollo) - * - Stripe billing - * - aamos-ledger (port 3250) - * - aamos-incidents (port 3303) - * - Auth & API keys + * - Operations API (port 3005) + * - Communications (port 3010) + * - Apollo CRM (port 3001) + * - AAMOS Ledger (port 3250) + * - AAMOS Incidents (port 3303) */ -import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import rateLimit from 'express-rate-limit'; -import { createProxyMiddleware } from 'http-proxy-middleware'; -import dotenv from 'dotenv'; +import http from 'http'; +import { GATEWAY_CONFIG, SERVICES } from './config'; +import { requireAuth, requireRole, verifyApiKey } from './auth'; +import { proxyRequest, checkServiceHealth } from './proxy'; -dotenv.config(); +// Service health status +const serviceHealth: Record = {}; -const app = express(); -const PORT = process.env.PORT || 3004; +// Rate limiting (simple in-memory) +const rateLimits: Record = {}; -// Middleware -app.use(helmet()); -app.use(cors()); -app.use(express.json()); +function checkRateLimit(identifier: string, maxRequests: number): boolean { + const now = Date.now(); + const windowStart = Math.floor(now / GATEWAY_CONFIG.rateLimitWindow) * GATEWAY_CONFIG.rateLimitWindow; + + if (!rateLimits[identifier] || rateLimits[identifier].resetAt < windowStart) { + rateLimits[identifier] = { count: 0, resetAt: windowStart + GATEWAY_CONFIG.rateLimitWindow }; + } + + rateLimits[identifier].count++; + return rateLimits[identifier].count <= maxRequests; +} -// Rate limiting -const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs -}); -app.use(limiter); +// CORS headers +function setCorsHeaders(res: http.ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key'); +} -// Health check -app.get('/health', (_req, res) => { - res.json({ - status: 'ok', - version: '0.1.0-master', - services: { - intelligence: 'http://localhost:3002', - apollo: '/api/apollo', - ledger: 'http://localhost:3250', - incidents: 'http://localhost:3303', +// Main server +const server = http.createServer(async (req, res) => { + setCorsHeaders(res); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + const url = req.url || '/'; + + // Health check + if (url === '/health') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + status: 'ok', + version: '0.1.0-gateway', + environment: GATEWAY_CONFIG.env, + services: Object.entries(serviceHealth).map(([name, health]) => ({ + name, + healthy: health.healthy, + latency: health.latency, + lastCheck: health.lastCheck, + error: health.error, + })), + })); + return; + } + + // API Key auth for external API access + const apiKey = req.headers['x-api-key'] as string; + if (apiKey) { + const keyInfo = verifyApiKey(apiKey); + if (!keyInfo.valid) { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Invalid API key' })); + return; } + if (!checkRateLimit(apiKey, keyInfo.rateLimit || 100)) { + res.statusCode = 429; + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + } + + // Route to services + if (url.startsWith('/api/v1/government') || url.startsWith('/api/v1/customers')) { + proxyRequest(req, res, SERVICES.operations); + return; + } else if (url.startsWith('/api/v1/communications')) { + proxyRequest(req, res, SERVICES.communications); + return; + } else if (url.startsWith('/api/v1/missions') || url.startsWith('/api/v1/artifacts')) { + proxyRequest(req, res, SERVICES.intelligence); + return; + } else if (url.startsWith('/api/apollo')) { + proxyRequest(req, res, SERVICES.apollo); + } else if (url.startsWith('/api/ledger')) { + proxyRequest(req, res, SERVICES.ledger); + } else if (url.startsWith('/api/incidents')) { + proxyRequest(req, res, SERVICES.incidents); + } else if (url === '/webhooks/stripe') { + // Stripe webhook β€” handle directly or proxy to billing + res.statusCode = 200; + res.end(JSON.stringify({ received: true })); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not Found', path: url })); + } +}); + +// Health check loop +async function updateHealth() { + for (const [name, service] of Object.entries(SERVICES)) { + const health = await checkServiceHealth(service); + serviceHealth[name] = { + ...health, + lastCheck: Date.now(), + }; + } +} + +// Initial health check +updateHealth(); +setInterval(updateHealth, 30000); // Every 30 seconds + +server.listen(GATEWAY_CONFIG.port, () => { + console.log(`πŸš€ LandveX Gateway running on port ${GATEWAY_CONFIG.port}`); + console.log(`πŸ“‘ Services:`); + Object.entries(SERVICES).forEach(([key, service]) => { + console.log(` - ${service.name}: http://${service.host}:${service.port}`); }); }); - -// Proxy to Intelligence Lab -app.use('/api/v1', createProxyMiddleware({ - target: 'http://localhost:3002', - changeOrigin: true, - pathRewrite: { '^/api/v1': '/api/v1' }, -})); - -// Proxy to Apollo CRM -app.use('/api/apollo', createProxyMiddleware({ - target: 'http://localhost:3001', - changeOrigin: true, - pathRewrite: { '^/api/apollo': '/api/apollo' }, -})); - -// Proxy to Ledger -app.use('/api/ledger', createProxyMiddleware({ - target: 'http://localhost:3250', - changeOrigin: true, - pathRewrite: { '^/api/ledger': '' }, -})); - -// Proxy to Incidents -app.use('/api/incidents', createProxyMiddleware({ - target: 'http://localhost:3303', - changeOrigin: true, - pathRewrite: { '^/api/incidents': '' }, -})); - -// Stripe webhook -app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { - // TODO: Implement Stripe webhook handling - res.json({ received: true }); -}); - -// Start server -app.listen(PORT, () => { - console.log(`πŸš€ LandveX Master Gateway running on port ${PORT}`); - console.log(`πŸ“‘ Proxying to:`); - console.log(` - Intelligence Lab: http://localhost:3002`); - console.log(` - Apollo CRM: http://localhost:3001`); - console.log(` - Ledger: http://localhost:3250`); - console.log(` - Incidents: http://localhost:3303`); -}); - -export default app; diff --git a/packages/gateway/src/proxy.mjs b/packages/gateway/src/proxy.mjs new file mode 100644 index 000000000..e59a4ebcd --- /dev/null +++ b/packages/gateway/src/proxy.mjs @@ -0,0 +1,87 @@ +/** + * LandveX Gateway β€” HTTP Proxy + * Routes requests to backend services + */ + +import http from 'http'; + +export function proxyRequest(req, res, target) { + const options = { + hostname: target.host, + port: target.port, + path: req.url, + method: req.method, + headers: { + ...req.headers, + host: `${target.host}:${target.port}`, + }, + timeout: 30000, + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode || 500, proxyRes.headers); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + console.error(`[PROXY ERROR] ${target.name}: ${err.message}`); + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: 'Bad Gateway', + service: target.name, + message: err.message, + })); + }); + + proxyReq.on('timeout', () => { + proxyReq.destroy(); + res.statusCode = 504; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: 'Gateway Timeout', + service: target.name, + message: 'Request timed out', + })); + }); + + req.pipe(proxyReq); +} + +export async function checkServiceHealth(service) { + const start = Date.now(); + + return new Promise((resolve) => { + const req = http.request({ + hostname: service.host, + port: service.port, + path: service.healthPath, + method: 'GET', + timeout: 5000, + }, (res) => { + resolve({ + healthy: res.statusCode === 200, + latency: Date.now() - start, + }); + }); + + req.on('error', (err) => { + resolve({ + healthy: false, + latency: Date.now() - start, + error: err.message, + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + healthy: false, + latency: Date.now() - start, + error: 'Timeout', + }); + }); + + req.end(); + }); +} diff --git a/packages/gateway/src/proxy.ts b/packages/gateway/src/proxy.ts new file mode 100644 index 000000000..87e84afe8 --- /dev/null +++ b/packages/gateway/src/proxy.ts @@ -0,0 +1,92 @@ +//** + * LandveX Gateway β€” HTTP Proxy + * Routes requests to backend services + */ + +import http from 'http'; +import { ServiceConfig } from './config'; + +export function proxyRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + target: ServiceConfig +): void { + const options: http.RequestOptions = { + hostname: target.host, + port: target.port, + path: req.url, + method: req.method, + headers: { + ...req.headers, + host: `${target.host}:${target.port}`, + }, + timeout: 30000, + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode || 500, proxyRes.headers); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + console.error(`[PROXY ERROR] ${target.name}: ${err.message}`); + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: 'Bad Gateway', + service: target.name, + message: err.message, + })); + }); + + proxyReq.on('timeout', () => { + proxyReq.destroy(); + res.statusCode = 504; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: 'Gateway Timeout', + service: target.name, + message: 'Request timed out', + })); + }); + + req.pipe(proxyReq); +} + +export async function checkServiceHealth(service: ServiceConfig): Promise<{ healthy: boolean; latency: number; error?: string }> { + const start = Date.now(); + + return new Promise((resolve) => { + const req = http.request({ + hostname: service.host, + port: service.port, + path: service.healthPath, + method: 'GET', + timeout: 5000, + }, (res) => { + resolve({ + healthy: res.statusCode === 200, + latency: Date.now() - start, + }); + }); + + req.on('error', (err) => { + resolve({ + healthy: false, + latency: Date.now() - start, + error: err.message, + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + healthy: false, + latency: Date.now() - start, + error: 'Timeout', + }); + }); + + req.end(); + }); +} diff --git a/packages/operations/package.json b/packages/operations/package.json new file mode 100644 index 000000000..7a7e1dda5 --- /dev/null +++ b/packages/operations/package.json @@ -0,0 +1,29 @@ +{ + "name": "@landvex/operations", + "version": "0.1.0", + "description": "LandveX Operations API β€” Government customers, billing, contracts", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "ts-node src/index.ts", + "start": "node dist/index.js", + "test": "jest" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.0.0", + "dotenv": "^16.3.1", + "pg": "^8.11.0" + }, + "devDependencies": { + "@types/express": "^4.17.17", + "@types/cors": "^2.8.13", + "@types/pg": "^8.10.0", + "typescript": "^5.1.0", + "ts-node": "^10.9.0", + "jest": "^29.5.0", + "@types/jest": "^29.5.0" + } +} diff --git a/packages/operations/src/index.mjs b/packages/operations/src/index.mjs new file mode 100644 index 000000000..d86be4ae1 --- /dev/null +++ b/packages/operations/src/index.mjs @@ -0,0 +1,25 @@ +/** + * LandveX Operations API + * + * Government customers, billing, contracts, self-service + * Port: 3005 + */ + +import http from 'http'; +import { handleRequest } from './router.mjs'; + +const PORT = process.env.PORT || 3005; + +const server = http.createServer(handleRequest); + +server.listen(PORT, () => { + console.log(`πŸ›οΈ LandveX Operations API running on port ${PORT}`); + console.log(`πŸ“‹ Endpoints:`); + console.log(` GET /health β†’ Health check`); + console.log(` GET /api/v1/government/me β†’ My profile`); + console.log(` PUT /api/v1/government/payment β†’ Update payment settings`); + console.log(` GET /api/v1/government/budget β†’ Budget overview`); + console.log(` POST /api/v1/government/areas β†’ Add watch area`); + console.log(` GET /api/v1/government/areas β†’ List watch areas`); + console.log(` POST /api/v1/government/users β†’ Invite user`); +}); diff --git a/packages/operations/src/index.ts b/packages/operations/src/index.ts new file mode 100644 index 000000000..5248c79ed --- /dev/null +++ b/packages/operations/src/index.ts @@ -0,0 +1,25 @@ +/** + * LandveX Operations API + * + * Government customers, billing, contracts, self-service + * Port: 3005 + */ + +import http from 'http'; +import { handleRequest } from './router.js'; + +const PORT = process.env.PORT || 3005; + +const server = http.createServer(handleRequest); + +server.listen(PORT, () => { + console.log(`πŸ›οΈ LandveX Operations API running on port ${PORT}`); + console.log(`πŸ“‹ Endpoints:`); + console.log(` GET /health β†’ Health check`); + console.log(` GET /api/v1/government/me β†’ My profile`); + console.log(` PUT /api/v1/government/payment β†’ Update payment settings`); + console.log(` GET /api/v1/government/budget β†’ Budget overview`); + console.log(` POST /api/v1/government/areas β†’ Add watch area`); + console.log(` GET /api/v1/government/areas β†’ List watch areas`); + console.log(` POST /api/v1/government/users β†’ Invite user`); +}); diff --git a/packages/operations/src/router.mjs b/packages/operations/src/router.mjs new file mode 100644 index 000000000..3339113e4 --- /dev/null +++ b/packages/operations/src/router.mjs @@ -0,0 +1,191 @@ +/** + * LandveX Operations API β€” Router + * Zero-dependency HTTP router + */ + +import http from 'http'; +import { URL } from 'url'; + +// In-memory store (replace with PostgreSQL) +const customers = {}; +const watchAreas = {}; +const users = {}; + +export function handleRequest(req, res) { + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const path = url.pathname; + + // Health check + if (path === '/health') { + res.end(JSON.stringify({ + status: 'ok', + version: '0.1.0-operations', + service: 'operations', + })); + return; + } + + // Government customer endpoints + if (path === '/api/v1/government/me') { + handleGetProfile(req, res); + } else if (path === '/api/v1/government/payment' && req.method === 'PUT') { + handleUpdatePayment(req, res); + } else if (path === '/api/v1/government/budget') { + handleGetBudget(req, res); + } else if (path === '/api/v1/government/areas' && req.method === 'POST') { + handleAddArea(req, res); + } else if (path === '/api/v1/government/areas' && req.method === 'GET') { + handleListAreas(req, res); + } else if (path === '/api/v1/government/users' && req.method === 'POST') { + handleInviteUser(req, res); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not Found', path })); + } +} + +function handleGetProfile(req, res) { + const profile = { + id: 'gov-001', + orgNumber: '212000-0142', + name: 'Stockholms stad', + type: 'kommun', + address: 'Stockholms stadshus, 111 83 Stockholm', + contactEmail: 'inkop@stockholm.se', + paymentSettings: { + method: 'invoice', + invoiceAddress: 'Stockholms stad, FE 101, 838 73 FrΓΆsΓΆn', + reference: 'Avd. Gatu- och fastighetskontoret', + ocrNumber: '2120000142', + }, + budgetLimits: { + monthly: 50000, + yearly: 600000, + alertAtPercent: 80, + blockAtPercent: 100, + }, + createdAt: '2024-01-15T10:00:00Z', + }; + + res.end(JSON.stringify(profile)); +} + +function handleUpdatePayment(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + res.end(JSON.stringify({ + success: true, + message: 'Payment settings updated', + settings: data, + })); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleGetBudget(req, res) { + const budget = { + monthlyLimit: 50000, + monthlyUsed: 32450, + monthlyRemaining: 17550, + percentUsed: 64.9, + yearlyLimit: 600000, + yearlyUsed: 187340, + yearlyRemaining: 412660, + yearlyPercentUsed: 31.2, + alertTriggered: false, + blockTriggered: false, + forecast: { + projectedYearly: 582000, + status: 'on_track', + }, + breakdown: [ + { category: 'Road Inspection', amount: 12400 }, + { category: 'Bridge Survey', amount: 8900 }, + { category: 'Property Scan', amount: 6750 }, + { category: 'Emergency Call', amount: 4400 }, + ], + }; + + res.end(JSON.stringify(budget)); +} + +function handleAddArea(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const area = { + id: `area-${Date.now()}`, + ...data, + createdAt: new Date().toISOString(), + }; + + const customerId = 'gov-001'; + if (!watchAreas[customerId]) watchAreas[customerId] = []; + watchAreas[customerId].push(area); + + res.statusCode = 201; + res.end(JSON.stringify(area)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleListAreas(req, res) { + const customerId = 'gov-001'; + const areas = watchAreas[customerId] || []; + + res.end(JSON.stringify({ + areas, + count: areas.length, + })); +} + +function handleInviteUser(req, res) { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const user = { + id: `user-${Date.now()}`, + email: data.email, + name: data.name, + role: data.role || 'viewer', + invitedAt: new Date().toISOString(), + status: 'pending', + }; + + const customerId = 'gov-001'; + if (!users[customerId]) users[customerId] = []; + users[customerId].push(user); + + res.statusCode = 201; + res.end(JSON.stringify(user)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} diff --git a/packages/operations/src/router.ts b/packages/operations/src/router.ts new file mode 100644 index 000000000..7bc3b6de7 --- /dev/null +++ b/packages/operations/src/router.ts @@ -0,0 +1,192 @@ +/** + * LandveX Operations API β€” Router + * Zero-dependency HTTP router + */ + +import http from 'http'; +import { URL } from 'url'; + +// In-memory store (replace with PostgreSQL) +const customers: Record = {}; +const watchAreas: Record = {}; +const users: Record = {}; + +export function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const path = url.pathname; + + // Health check + if (path === '/health') { + res.end(JSON.stringify({ + status: 'ok', + version: '0.1.0-operations', + service: 'operations', + })); + return; + } + + // Government customer endpoints + if (path === '/api/v1/government/me') { + handleGetProfile(req, res); + } else if (path === '/api/v1/government/payment' && req.method === 'PUT') { + handleUpdatePayment(req, res); + } else if (path === '/api/v1/government/budget') { + handleGetBudget(req, res); + } else if (path === '/api/v1/government/areas' && req.method === 'POST') { + handleAddArea(req, res); + } else if (path === '/api/v1/government/areas' && req.method === 'GET') { + handleListAreas(req, res); + } else if (path === '/api/v1/government/users' && req.method === 'POST') { + handleInviteUser(req, res); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not Found', path })); + } +} + +function handleGetProfile(req: http.IncomingMessage, res: http.ServerResponse): void { + // Mock profile β€” in real implementation, extract from JWT + const profile = { + id: 'gov-001', + orgNumber: '212000-0142', + name: 'Stockholms stad', + type: 'kommun', + address: 'Stockholms stadshus, 111 83 Stockholm', + contactEmail: 'inkop@stockholm.se', + paymentSettings: { + method: 'invoice', + invoiceAddress: 'Stockholms stad, FE 101, 838 73 FrΓΆsΓΆn', + reference: 'Avd. Gatu- och fastighetskontoret', + ocrNumber: '2120000142', + }, + budgetLimits: { + monthly: 50000, + yearly: 600000, + alertAtPercent: 80, + blockAtPercent: 100, + }, + createdAt: '2024-01-15T10:00:00Z', + }; + + res.end(JSON.stringify(profile)); +} + +function handleUpdatePayment(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + res.end(JSON.stringify({ + success: true, + message: 'Payment settings updated', + settings: data, + })); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleGetBudget(req: http.IncomingMessage, res: http.ServerResponse): void { + const budget = { + monthlyLimit: 50000, + monthlyUsed: 32450, + monthlyRemaining: 17550, + percentUsed: 64.9, + yearlyLimit: 600000, + yearlyUsed: 187340, + yearlyRemaining: 412660, + yearlyPercentUsed: 31.2, + alertTriggered: false, + blockTriggered: false, + forecast: { + projectedYearly: 582000, + status: 'on_track', + }, + breakdown: [ + { category: 'Road Inspection', amount: 12400 }, + { category: 'Bridge Survey', amount: 8900 }, + { category: 'Property Scan', amount: 6750 }, + { category: 'Emergency Call', amount: 4400 }, + ], + }; + + res.end(JSON.stringify(budget)); +} + +function handleAddArea(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const area = { + id: `area-${Date.now()}`, + ...data, + createdAt: new Date().toISOString(), + }; + + const customerId = 'gov-001'; + if (!watchAreas[customerId]) watchAreas[customerId] = []; + watchAreas[customerId].push(area); + + res.statusCode = 201; + res.end(JSON.stringify(area)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} + +function handleListAreas(req: http.IncomingMessage, res: http.ServerResponse): void { + const customerId = 'gov-001'; + const areas = watchAreas[customerId] || []; + + res.end(JSON.stringify({ + areas, + count: areas.length, + })); +} + +function handleInviteUser(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body); + const user = { + id: `user-${Date.now()}`, + email: data.email, + name: data.name, + role: data.role || 'viewer', + invitedAt: new Date().toISOString(), + status: 'pending', + }; + + const customerId = 'gov-001'; + if (!users[customerId]) users[customerId] = []; + users[customerId].push(user); + + res.statusCode = 201; + res.end(JSON.stringify(user)); + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); +} diff --git a/packages/operations/tsconfig.json b/packages/operations/tsconfig.json new file mode 100644 index 000000000..9424ecd5a --- /dev/null +++ b/packages/operations/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}