import express from 'express'; import Database from 'better-sqlite3'; import multer from 'multer'; import { randomUUID } from 'crypto'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = 7060; const DB_PATH = '/opt/amos/data/quixzoom-missions.db'; const UPLOAD_DIR = '/opt/amos/data/quixzoom-uploads'; const ATLAS_URL = 'http://127.0.0.1:7002'; const INGEST_URL = 'http://127.0.0.1:7001'; const PAYROLL_URL = 'http://127.0.0.1:6001'; const SELF_URL = 'http://127.0.0.1:7060'; fs.mkdirSync(UPLOAD_DIR, { recursive: true }); const app = express(); app.use(express.json()); const upload = multer({ dest: UPLOAD_DIR, limits: { fileSize: 50 * 1024 * 1024 }, }); // Serve uploaded images so 3D ingest can download them app.use('/uploads', express.static(UPLOAD_DIR)); // --------------------------------------------------------------------------- // Database // --------------------------------------------------------------------------- const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS missions ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, lat REAL NOT NULL, lon REAL NOT NULL, address TEXT, status TEXT NOT NULL DEFAULT 'open', zoomer_id TEXT, reward_sek INTEGER NOT NULL DEFAULT 0, mission_type TEXT NOT NULL DEFAULT 'inspection', brief_url TEXT, ingest_job_id TEXT, payment_ref TEXT, created_at TEXT NOT NULL, accepted_at TEXT, submitted_at TEXT, approved_at TEXT ); CREATE INDEX IF NOT EXISTS idx_missions_status ON missions(status); CREATE INDEX IF NOT EXISTS idx_missions_coords ON missions(lat, lon); `); // --------------------------------------------------------------------------- // Seed demo data // --------------------------------------------------------------------------- function seedDemo() { const { c } = db.prepare('SELECT COUNT(*) as c FROM missions').get(); if (c > 0) return; const demos = [ { title: 'Fasadinspektion Kungsgatan 12', description: 'Dokumentera brandskadat fasadparti. 3D-scanning av hela fronten.', lat: 59.3349, lon: 18.0632, address: 'Kungsgatan 12, Stockholm', reward_sek: 1800, mission_type: 'facade_scan', }, { title: 'Takskanning Södermalm', description: 'Stormskada på tak efter aprilstormen. Fotografera hela takytan.', lat: 59.3163, lon: 18.0738, address: 'Hornsgatan 55, Stockholm', reward_sek: 2400, mission_type: 'roof_scan', }, { title: 'Vägbana Strandvägen – potthålskarta', description: 'Kartlägg potthål och sprickor längs Strandvägen 200 m.', lat: 59.3318, lon: 18.0892, address: 'Strandvägen, Östermalm, Stockholm', reward_sek: 900, mission_type: 'road_inspection', }, { title: 'Fuktskadat källarplan Odengatan 44', description: 'Dokumentera vattenskada och mögeltillväxt i källare och bottenvåning.', lat: 59.3421, lon: 18.0559, address: 'Odengatan 44, Stockholm', reward_sek: 3200, mission_type: 'interior_scan', }, { title: 'Stormfällda träd Djurgårdsparken', description: 'Inventera skadade träd och blockerade gångvägar efter stormen.', lat: 59.3258, lon: 18.1043, address: 'Djurgårdsparken, Stockholm', reward_sek: 1200, mission_type: 'vegetation_survey', }, ]; const insert = db.prepare(` INSERT INTO missions (id, title, description, lat, lon, address, status, reward_sek, mission_type, brief_url, created_at) VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?) `); const insertMany = db.transaction(() => { for (const d of demos) { const id = `mission-${randomUUID().slice(0, 8)}`; const briefUrl = `${ATLAS_URL}/coverage/${d.lat}/${d.lon}/500`; insert.run(id, d.title, d.description, d.lat, d.lon, d.address, d.reward_sek, d.mission_type, briefUrl, new Date().toISOString()); } }); insertMany(); console.log('[quiXzoom-mission] Seeded 5 demo missions in Stockholm'); } seedDemo(); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function registerAtlasObject(mission) { try { const res = await fetch(`${ATLAS_URL}/objects/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: mission.title, object_type: mission.mission_type, location_lat: mission.lat, location_lon: mission.lon, s3_model_key: `missions/${mission.id}/brief.glb`, }), signal: AbortSignal.timeout(5000), }); if (!res.ok) return null; const obj = await res.json(); return `${ATLAS_URL}/objects/${obj.id}/model`; } catch { return `${ATLAS_URL}/coverage/${mission.lat}/${mission.lon}/500`; } } async function triggerIngest(missionId, lat, lon, imageUrls) { const res = await fetch(`${INGEST_URL}/jobs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ area_id: `${lat.toFixed(4)},${lon.toFixed(4)}`, image_urls: imageUrls, }), signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`Ingest returned ${res.status}`); return res.json(); } async function triggerPayroll(mission) { try { const res = await fetch(`${PAYROLL_URL}/mission-payment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mission_id: mission.id, zoomer_id: mission.zoomer_id, amount_sek: mission.reward_sek, description: `quiXzoom uppdrag: ${mission.title}`, approved_at: new Date().toISOString(), }), signal: AbortSignal.timeout(5000), }); if (!res.ok) return { status: 'queued', ref: `PAY-${randomUUID().slice(0, 8)}`, warning: `payroll HTTP ${res.status}` }; const body = await res.json(); return { status: 'triggered', ref: body.id || body.ref || `PAY-${randomUUID().slice(0, 8)}`, ...body }; } catch (err) { // Payroll service may not be on :6001 yet — store ref and continue return { status: 'queued', ref: `PAY-${randomUUID().slice(0, 8)}`, warning: err.message }; } } // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- // POST /missions app.post('/missions', async (req, res) => { const { title, description, lat, lon, address, reward_sek = 0, mission_type = 'inspection' } = req.body; if (!title || lat == null || lon == null) { return res.status(400).json({ error: 'title, lat and lon are required' }); } const id = `mission-${randomUUID().slice(0, 8)}`; const now = new Date().toISOString(); const mission = { id, title, description, lat: parseFloat(lat), lon: parseFloat(lon), address, reward_sek: parseInt(reward_sek) || 0, mission_type }; const briefUrl = await registerAtlasObject(mission); mission.brief_url = briefUrl; db.prepare(` INSERT INTO missions (id, title, description, lat, lon, address, status, reward_sek, mission_type, brief_url, created_at) VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?) `).run(id, title, description || null, mission.lat, mission.lon, address || null, mission.reward_sek, mission_type, briefUrl, now); res.status(201).json(db.prepare('SELECT * FROM missions WHERE id = ?').get(id)); }); // GET /missions?bbox=lat1,lon1,lat2,lon2 app.get('/missions', (req, res) => { const { bbox, status } = req.query; let rows = db.prepare("SELECT * FROM missions WHERE status IN ('open', 'assigned')").all(); if (status) { rows = db.prepare('SELECT * FROM missions WHERE status = ?').all(status); } if (bbox) { const parts = bbox.split(',').map(parseFloat); if (parts.length !== 4 || parts.some(isNaN)) { return res.status(400).json({ error: 'bbox must be lat1,lon1,lat2,lon2' }); } const [lat1, lon1, lat2, lon2] = parts; const minLat = Math.min(lat1, lat2); const maxLat = Math.max(lat1, lat2); const minLon = Math.min(lon1, lon2); const maxLon = Math.max(lon1, lon2); rows = rows.filter(m => m.lat >= minLat && m.lat <= maxLat && m.lon >= minLon && m.lon <= maxLon); } res.json({ missions: rows, count: rows.length }); }); // GET /missions/:id app.get('/missions/:id', (req, res) => { const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id); if (!mission) return res.status(404).json({ error: 'Mission not found' }); res.json({ ...mission, viewer_url: mission.brief_url, ingest_status_url: mission.ingest_job_id ? `${INGEST_URL}/jobs/${mission.ingest_job_id}` : null, }); }); // POST /missions/:id/accept app.post('/missions/:id/accept', (req, res) => { const { zoomer_id } = req.body; if (!zoomer_id) return res.status(400).json({ error: 'zoomer_id is required' }); const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id); if (!mission) return res.status(404).json({ error: 'Mission not found' }); if (mission.status !== 'open') return res.status(409).json({ error: `Mission is already ${mission.status}` }); const now = new Date().toISOString(); db.prepare("UPDATE missions SET status='assigned', zoomer_id=?, accepted_at=? WHERE id=?") .run(zoomer_id, now, req.params.id); res.json(db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id)); }); // POST /missions/:id/submit (multipart images) app.post('/missions/:id/submit', upload.array('images', 50), async (req, res) => { const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id); if (!mission) return res.status(404).json({ error: 'Mission not found' }); if (!['assigned', 'open'].includes(mission.status)) { return res.status(409).json({ error: `Cannot submit from status: ${mission.status}` }); } const files = req.files || []; if (files.length === 0) return res.status(400).json({ error: 'At least one image is required' }); // Rename uploads to have proper extensions for the ingest service const imageUrls = files.map(f => { const ext = path.extname(f.originalname) || '.jpg'; const newName = `${f.filename}${ext}`; fs.renameSync(f.path, path.join(UPLOAD_DIR, newName)); return `${SELF_URL}/uploads/${newName}`; }); let ingestJob = null; try { ingestJob = await triggerIngest(req.params.id, mission.lat, mission.lon, imageUrls); } catch (err) { console.error('[quiXzoom-mission] Ingest trigger failed:', err.message); } const now = new Date().toISOString(); db.prepare("UPDATE missions SET status='submitted', submitted_at=?, ingest_job_id=? WHERE id=?") .run(now, ingestJob?.job_id || null, req.params.id); res.json({ mission: db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id), images_uploaded: imageUrls.length, image_urls: imageUrls, ingest_job: ingestJob, }); }); // POST /missions/:id/approve app.post('/missions/:id/approve', async (req, res) => { const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id); if (!mission) return res.status(404).json({ error: 'Mission not found' }); if (mission.status !== 'submitted') { return res.status(409).json({ error: `Mission must be submitted before approval (current: ${mission.status})` }); } const payment = await triggerPayroll(mission); const now = new Date().toISOString(); db.prepare("UPDATE missions SET status='approved', approved_at=?, payment_ref=? WHERE id=?") .run(now, payment.ref, req.params.id); res.json({ mission: db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id), payment, }); }); // GET /health app.get('/health', (_req, res) => { const { c } = db.prepare('SELECT COUNT(*) as c FROM missions').get(); res.json({ status: 'ok', service: 'quixzoom-mission-engine', port: PORT, missions_total: c, uptime_seconds: Math.floor(process.uptime()), timestamp: new Date().toISOString(), }); }); app.listen(PORT, () => { console.log(`[quiXzoom-mission] Mission Engine running on port ${PORT}`); });