Files
boc/quixzoom-mission-index-backup-1781700391.mjs
T

350 lines
12 KiB
JavaScript
Raw Normal View History

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}`);
});