bae705aa97
- 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
350 lines
12 KiB
JavaScript
350 lines
12 KiB
JavaScript
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}`);
|
||
});
|