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