/** * QUIXZOOM Zoomer API * * Endpoints för Zoomer-hantering: * - CRUD för Zoomers * - Onboarding progress * - Quality scores * - Mission assignment * - Regional coordinators */ const express = require('express'); const router = express.Router(); // Mock database - replace with real PostgreSQL const zoomers = new Map(); const missions = new Map(); /** * ============================================================ * ZOOMER CRUD * ============================================================ */ // List all zoomers router.get('/zoomers', (req, res) => { const { status, level, area } = req.query; let results = Array.from(zoomers.values()); if (status) { results = results.filter(z => z.status === status); } if (level) { results = results.filter(z => z.level === parseInt(level)); } if (area) { results = results.filter(z => z.area === area); } res.json({ success: true, count: results.length, zoomers: results, }); }); // Get single zoomer router.get('/zoomers/:id', (req, res) => { const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } res.json({ success: true, zoomer, }); }); // Create zoomer router.post('/zoomers', (req, res) => { const { name, email, phone, location } = req.body; const zoomer = { id: `zoomer_${Date.now()}`, name, email, phone, location, level: 0, status: 'onboarding', role: 'zoomer', createdAt: new Date().toISOString(), scores: { overall: 0, gps: [], image: [], corrections: 0, approvedObservations: 0, totalObservations: 0, confirmations: 0, responseTime: [], completionRate: [], }, }; zoomers.set(zoomer.id, zoomer); res.status(201).json({ success: true, zoomer, }); }); // Update zoomer router.patch('/zoomers/:id', (req, res) => { const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } Object.assign(zoomer, req.body); zoomer.updatedAt = new Date().toISOString(); res.json({ success: true, zoomer, }); }); // Delete zoomer router.delete('/zoomers/:id', (req, res) => { const deleted = zoomers.delete(req.params.id); if (!deleted) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } res.json({ success: true, message: 'Zoomer deleted', }); }); /** * ============================================================ * ONBOARDING * ============================================================ */ // Get onboarding progress router.get('/zoomers/:id/onboarding', (req, res) => { const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } const progress = { level: zoomer.level, status: zoomer.status, completedLevels: zoomer.level >= 1, currentLevel: zoomer.level < 4 ? zoomer.level + 1 : 4, canStartNext: zoomer.level < 4, }; res.json({ success: true, progress, }); }); // Complete level router.post('/zoomers/:id/onboarding/complete', (req, res) => { const { level } = req.body; const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } if (level !== zoomer.level + 1) { return res.status(400).json({ success: false, error: 'Invalid level progression', }); } zoomer.level = level; if (level === 3) { zoomer.status = 'active'; } else if (level === 4) { zoomer.status = 'trusted'; } res.json({ success: true, zoomer, }); }); /** * ============================================================ * QUALITY SCORES * ============================================================ */ // Get quality score router.get('/zoomers/:id/score', (req, res) => { const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } const score = calculateQualityScore(zoomer); res.json({ success: true, score, }); }); // Update scores from observation router.post('/zoomers/:id/scores', (req, res) => { const { observation } = req.body; const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } // Update scores if (observation.location?.accuracy) { zoomer.scores.gps.push(observation.location.accuracy); } if (observation.quality?.overall) { zoomer.scores.image.push(observation.quality.overall); } if (observation.aiCorrections) { zoomer.scores.corrections += observation.aiCorrections; } zoomer.scores.totalObservations++; if (observation.approved) { zoomer.scores.approvedObservations++; } if (observation.responseTime) { zoomer.scores.responseTime.push(observation.responseTime); } if (observation.missionCompleted !== undefined) { zoomer.scores.completionRate.push(observation.missionCompleted ? 1 : 0); } const score = calculateQualityScore(zoomer); res.json({ success: true, score, }); }); function calculateQualityScore(zoomer) { const scores = zoomer.scores; const avgGps = scores.gps.length > 0 ? scores.gps.reduce((a, b) => a + b, 0) / scores.gps.length : 999; const gpsScore = Math.max(0, 1 - (avgGps / 10)); const avgImage = scores.image.length > 0 ? scores.image.reduce((a, b) => a + b, 0) / scores.image.length : 0; const correctionRate = scores.totalObservations > 0 ? scores.corrections / scores.totalObservations : 0; const correctionScore = Math.max(0, 1 - correctionRate); const approvalRate = scores.totalObservations > 0 ? scores.approvedObservations / scores.totalObservations : 0; const confirmationScore = Math.min(1, scores.confirmations / 10); const avgResponse = scores.responseTime.length > 0 ? scores.responseTime.reduce((a, b) => a + b, 0) / scores.responseTime.length : 3600; const responseScore = Math.max(0, 1 - (avgResponse / 3600)); const avgCompletion = scores.completionRate.length > 0 ? scores.completionRate.reduce((a, b) => a + b, 0) / scores.completionRate.length : 0; const overall = gpsScore * 0.20 + avgImage * 0.20 + correctionScore * 0.15 + approvalRate * 0.20 + confirmationScore * 0.10 + responseScore * 0.10 + avgCompletion * 0.05; return { overall: Math.round(overall * 100) / 100, breakdown: { gps: Math.round(gpsScore * 100) / 100, image: Math.round(avgImage * 100) / 100, corrections: Math.round(correctionScore * 100) / 100, approval: Math.round(approvalRate * 100) / 100, confirmations: Math.round(confirmationScore * 100) / 100, response: Math.round(responseScore * 100) / 100, completion: Math.round(avgCompletion * 100) / 100, }, }; } /** * ============================================================ * MISSIONS * ============================================================ */ // Assign mission to zoomer router.post('/zoomers/:id/missions', (req, res) => { const { missionId } = req.body; const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } const mission = missions.get(missionId); if (!mission) { return res.status(404).json({ success: false, error: 'Mission not found', }); } const score = calculateQualityScore(zoomer); const suitability = calculateSuitability(score, mission); res.json({ success: true, assignment: { zoomerId: zoomer.id, missionId, suitability, recommended: suitability > 0.8, }, }); }); function calculateSuitability(score, mission) { if (!score) return 0; let suitability = score.overall; if (mission.complexity === 'high' && score.overall < 0.8) { suitability *= 0.5; } if (mission.timeOfDay === 'night' && score.breakdown.image < 0.7) { suitability *= 0.7; } return Math.round(suitability * 100) / 100; } /** * ============================================================ * REGIONAL COORDINATORS * ============================================================ */ // List coordinators router.get('/coordinators', (req, res) => { const coordinators = Array.from(zoomers.values()) .filter(z => z.role === 'regional_coordinator'); res.json({ success: true, count: coordinators.length, coordinators, }); }); // Promote to coordinator router.post('/zoomers/:id/coordinator', (req, res) => { const { area } = req.body; const zoomer = zoomers.get(req.params.id); if (!zoomer) { return res.status(404).json({ success: false, error: 'Zoomer not found', }); } const score = calculateQualityScore(zoomer); if (score.overall < 0.8 || zoomer.level < 4) { return res.status(400).json({ success: false, error: 'Zoomer does not meet requirements for coordinator', }); } zoomer.role = 'regional_coordinator'; zoomer.area = area; res.json({ success: true, zoomer, }); }); /** * ============================================================ * STATS * ============================================================ */ // Get system stats router.get('/stats', (req, res) => { const allZoomers = Array.from(zoomers.values()); const stats = { total: allZoomers.length, byLevel: { 0: allZoomers.filter(z => z.level === 0).length, 1: allZoomers.filter(z => z.level === 1).length, 2: allZoomers.filter(z => z.level === 2).length, 3: allZoomers.filter(z => z.level === 3).length, 4: allZoomers.filter(z => z.level === 4).length, }, byStatus: { onboarding: allZoomers.filter(z => z.status === 'onboarding').length, active: allZoomers.filter(z => z.status === 'active').length, trusted: allZoomers.filter(z => z.status === 'trusted').length, }, coordinators: allZoomers.filter(z => z.role === 'regional_coordinator').length, avgScore: allZoomers.length > 0 ? allZoomers.reduce((sum, z) => sum + calculateQualityScore(z).overall, 0) / allZoomers.length : 0, }; res.json({ success: true, stats, }); }); module.exports = router; // Demo data if (require.main === module) { const app = express(); app.use(express.json()); app.use('/api/v1', router); // Add demo zoomer const demoZoomer = { id: 'zoomer_bangkok_001', name: 'Somchai', email: 'somchai@example.com', phone: '+66-81-234-5678', location: 'Bangkok, Sukhumvit', level: 4, status: 'trusted', role: 'regional_coordinator', area: 'Sukhumvit', createdAt: '2026-06-15', scores: { overall: 0.84, gps: [3, 4, 3, 5, 4, 3, 4, 5, 3, 4], image: [0.9, 0.85, 0.9, 0.8, 0.9, 0.85, 0.9, 0.9, 0.85, 0.9], corrections: 2, approvedObservations: 45, totalObservations: 50, confirmations: 8, responseTime: [300, 450, 200, 600, 350, 400, 250, 500, 300, 350], completionRate: [1, 1, 1, 1, 0, 1, 1, 1, 1, 1], }, }; zoomers.set(demoZoomer.id, demoZoomer); const PORT = 3003; app.listen(PORT, () => { console.log(`Zoomer API running on port ${PORT}`); console.log('Endpoints:'); console.log(' GET /api/v1/zoomers'); console.log(' GET /api/v1/zoomers/:id'); console.log(' POST /api/v1/zoomers'); console.log(' GET /api/v1/zoomers/:id/score'); console.log(' GET /api/v1/stats'); }); }