Files
boc/quixzoom-mission-index-backup-1781700391.mjs
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

350 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
});