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
516 lines
12 KiB
JavaScript
516 lines
12 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
}
|