// routes/activities.js const express = require("express"); const router = express.Router(); const path = require("path"); const fsp = require("fs/promises"); const db = require("../db/knex"); const wss = require("../ws-server"); const normalizeActivity = require("../helpers/normalizeActivity"); const { parseFitTrackPoints } = require("../api_v1/scanner/parsers/parseFit"); const { getUserAccess, filterReadableSources, canWrite, } = require("../api_v1/userAccess"); // ----------------------------------------------------- // File helpers hard delete attività // ----------------------------------------------------- const PUBLIC_ROOT = path.resolve(__dirname, "..", "public"); const ACTIVITIES_ROOT = path.join(PUBLIC_ROOT, "activities"); const DEL_ACTIVITIES_ROOT = path.join(PUBLIC_ROOT, "del_activities"); function getActivityReadAccess(req, res) { const loggedUser = String(req.user?.name ?? "").trim(); if (!loggedUser) { res.status(401).json({ error: "Unauthorized", }); return null; } const access = getUserAccess(loggedUser); if (!access) { res.status(403).json({ error: "Utente non configurato", user: loggedUser, }); return null; } return access; } function safeJoin(base, ...parts) { const resolvedBase = path.resolve(base); const resolved = path.resolve(base, ...parts); const isInsideBase = resolved === resolvedBase || resolved.startsWith(`${resolvedBase}${path.sep}`); if (!isInsideBase) { throw new Error(`Path non sicuro: ${resolved}`); } return resolved; } function getOriginalActivityPath(row) { return safeJoin( ACTIVITIES_ROOT, row.user, "original", row.cartella || "", row.file_name, ); } function getDeletedActivityPath(row) { return safeJoin( DEL_ACTIVITIES_ROOT, row.user, "original", row.cartella || "", row.file_name, ); } async function pathExists(filePath) { try { await fsp.access(filePath); return true; } catch { return false; } } async function moveFileSafe(src, dst) { if (!src || !dst) return false; const exists = await pathExists(src); if (!exists) { console.warn(`⚠️ File attività non trovato, skip move: ${src}`); return false; } // Crea automaticamente public/del_activities//original// await fsp.mkdir(path.dirname(dst), { recursive: true }); try { await fsp.rename(src, dst); return true; } catch (err) { // fallback se source/destination sono su filesystem diversi if (err.code === "EXDEV") { await fsp.copyFile(src, dst); await fsp.unlink(src); return true; } throw err; } } // ----------------------------------------------------- // POST /activities // Salva attività inviata dallo scanner locale // ----------------------------------------------------- router.post("/", async (req, res) => { const a = req.body; const start = Date.now(); console.log(`📥 [POST /activities] Richiesta at=${new Date().toISOString()}`); console.log("📥 Body:", a); try { const loggedUser = String(req.user?.name ?? "").trim(); const targetSource = String(a?.user ?? "").trim(); if (!loggedUser) { return res.status(401).json({ ok: false, error: "Unauthorized", }); } const access = getUserAccess(loggedUser); if (!access) { return res.status(403).json({ ok: false, error: "Utente non configurato", user: loggedUser, }); } if (!targetSource) { return res.status(400).json({ ok: false, error: "Missing activity user/source", }); } if (!canWrite(loggedUser, targetSource)) { console.warn("⛔ Inserimento attività non autorizzato", { loggedUser, targetSource, id: a?.id, }); return res.status(403).json({ ok: false, error: "Write not allowed", targetSource, }); } console.log( `🏃 [DB INSERT] Salvo activity id=${a.id} user=${targetSource}`, ); const now = new Date().toISOString(); await db("activities") .insert({ // Mantieni qui tutti i campi attuali id: a.id, user: targetSource, cartella: a.cartella, file_name: a.file_name, file_extension: a.file_extension, format: a.format, display_name: a.display_name, route_name: a.route_name, title: a.title, description: a.description, notes: a.notes, sport: a.sport, activity_type: a.activity_type, device_manufacturer: a.device_manufacturer, device_model: a.device_model, source_app: a.source_app, start_time_millis: a.start_time_millis, end_time_millis: a.end_time_millis, duration_millis: a.duration_millis, distance_meters: a.distance_meters, elevation_gain_meters: a.elevation_gain_meters, elevation_loss_meters: a.elevation_loss_meters, min_elevation_meters: a.min_elevation_meters, max_elevation_meters: a.max_elevation_meters, file_distance_meters: a.file_distance_meters, file_elevation_gain_meters: a.file_elevation_gain_meters, computed_distance_meters: a.computed_distance_meters, computed_elevation_gain_meters: a.computed_elevation_gain_meters, average_speed_mps: a.average_speed_mps, max_speed_mps: a.max_speed_mps, average_pace_sec_per_km: a.average_pace_sec_per_km, average_heart_rate: a.average_heart_rate, max_heart_rate: a.max_heart_rate, average_cadence: a.average_cadence, max_cadence: a.max_cadence, calories: a.calories, power_avg: a.power_avg, power_max: a.power_max, point_count: a.point_count, segment_count: a.segment_count, lap_count: a.lap_count, min_latitude: a.min_latitude, max_latitude: a.max_latitude, min_longitude: a.min_longitude, max_longitude: a.max_longitude, center_latitude: a.center_latitude, center_longitude: a.center_longitude, size_bytes: a.size_bytes, last_modified_millis: a.last_modified_millis, date_added_millis: a.date_added_millis, date_modified_millis: a.date_modified_millis, last_scan_millis: a.last_scan_millis, is_favorite: a.is_favorite, is_hidden: a.is_hidden, rating: a.rating, tags: a.tags ? JSON.stringify(a.tags) : null, track_preview: a.track_preview ? JSON.stringify(a.track_preview) : null, scan_status: a.scan_status, scan_error: a.scan_error, diagnostics: a.diagnostics ? JSON.stringify(a.diagnostics) : null, location: a.location ? JSON.stringify(a.location) : null, path: a.path, fast_hash: a.fast_hash, deleted_at: a.deleted_at || null, created_at: now, updated_at: now, }) .onConflict("id") .merge(); wss.broadcastToUser(targetSource, { type: "added", id: a.id, entity: "activity", }); console.log( `✅ [POST /activities] Activity id=${a.id} salvata ` + `durata=${Date.now() - start}ms`, ); return res.json({ ok: true, }); } catch (err) { console.error("❌ Errore POST /activities:", err); return res.status(500).json({ ok: false, error: err.message, }); } }); // ----------------------------------------------------- // GET /activities/changes // Progressive sync // ----------------------------------------------------- router.get("/changes", async (req, res) => { const access = getActivityReadAccess(req, res); if (!access) { return; } const since = req.query.since; const start = Date.now(); console.log( `🔄 [SYNC SINCE] GET /activities/changes ` + `user=${access.name} since=${since} ` + `at=${new Date().toISOString()}`, ); console.log("🔐 Sorgenti leggibili:", access.readableSources); if (!since) { return res.status(400).json({ error: "Missing ?since=timestamp", }); } if (access.readableSources.length === 0) { return res.json([]); } try { const rows = await db("activities") .whereIn("user", access.readableSources) .andWhere((builder) => { builder .where("created_at", ">", since) .orWhere("updated_at", ">", since); }) .orderBy("updated_at", "desc"); console.log( `🔄 [SYNC SINCE] Risposta: ${rows.length} attività ` + `durata=${Date.now() - start}ms`, ); return res.json(rows.map(normalizeActivity)); } catch (err) { console.error("❌ Errore GET /activities/changes:", err); return res.status(500).json({ error: err.message, }); } }); // ----------------------------------------------------- // GET /activities/byIds // ----------------------------------------------------- router.get("/byIds", async (req, res) => { const access = getActivityReadAccess(req, res); if (!access) { return; } const ids = req.query.id; const requestedUsers = req.query.user; const start = Date.now(); console.log(`📤 [GET /activities/byIds] at=${new Date().toISOString()}`); console.log("📤 ids:", ids); console.log("📤 requestedUsers:", requestedUsers); console.log("🔐 readableSources:", access.readableSources); if (!ids) { return res.json([]); } const idList = Array.isArray(ids) ? ids : [ids]; const allowedSources = filterReadableSources(access.name, requestedUsers); console.log("🔐 allowedSources:", allowedSources); if (allowedSources.length === 0) { return res.json([]); } try { const rows = await db("activities") .whereIn("id", idList) .whereIn("user", allowedSources); console.log( `📤 [GET /activities/byIds] trovate ${rows.length} attività ` + `durata=${Date.now() - start}ms`, ); return res.json(rows.map(normalizeActivity)); } catch (err) { console.error("❌ Errore GET /activities/byIds:", err); return res.status(500).json({ error: err.message, }); } }); // ----------------------------------------------------- // GET /activities // Full sync // ----------------------------------------------------- router.get("/", async (req, res) => { const access = getActivityReadAccess(req, res); if (!access) { return; } const start = Date.now(); console.log( `📤 [FULL SYNC] GET /activities da=${access.name} ` + `at=${new Date().toISOString()}`, ); console.log("🔐 Sorgenti leggibili:", access.readableSources); if (access.readableSources.length === 0) { return res.json([]); } try { const rows = await db("activities") .whereIn("user", access.readableSources) .orderBy("start_time_millis", "desc"); console.log( `📤 [FULL SYNC] Risposta: ${rows.length} attività ` + `durata=${Date.now() - start}ms`, ); return res.json(rows.map(normalizeActivity)); } catch (err) { console.error("❌ Errore GET /activities:", err); return res.status(500).json({ error: err.message, }); } }); // ----------------------------------------------------- // POST /activities/initDB // Reset DB attività // - Admin: cancella tutte le attività // - Utente normale: cancella solo le proprie // ----------------------------------------------------- router.post("/initDB", async (req, res) => { const loggedUser = String(req.user?.name ?? "").trim(); const start = Date.now(); try { if (!loggedUser) { return res.status(401).json({ ok: false, error: "Unauthorized", }); } const access = getUserAccess(loggedUser); if (!access) { return res.status(403).json({ ok: false, error: "Utente non configurato", user: loggedUser, }); } const writableSources = access.writableSources; console.log("🧹 [POST /activities/initDB]", { requestedBy: loggedUser, writableSources, }); if (writableSources.length === 0) { return res.json({ ok: true, scope: "none", user: loggedUser, writableSources: [], deletedActivities: 0, deletedHardRecords: 0, deletedSyncRecords: 0, duration_ms: Date.now() - start, message: "Nessuna sorgente scrivibile da svuotare", }); } const result = await db.transaction(async (trx) => { const deletedActivities = await trx("activities") .whereIn("user", writableSources) .del(); const deletedHardRecords = await trx("deleted_hard_activities") .whereIn("user", writableSources) .del(); /* * sync_done_activities non contiene attualmente la colonna user. * Possiamo limitarlo ai device scrivibili usando device_id. */ const deletedSyncRecords = await trx("sync_done_activities") .whereIn("device_id", writableSources) .del(); return { deletedActivities, deletedHardRecords, deletedSyncRecords, }; }); return res.json({ ok: true, scope: "writableSources", user: loggedUser, writableSources, ...result, duration_ms: Date.now() - start, message: `DB attività svuotato per le sorgenti scrivibili ` + `di ${loggedUser}`, }); } catch (err) { console.error("❌ Errore POST /activities/initDB:", err); return res.status(500).json({ ok: false, error: err.message, duration_ms: Date.now() - start, }); } }); // ----------------------------------------------------- // POST /activities/initDBuser // Reset DB attività per utente specifico // Solo Admin // ----------------------------------------------------- router.post("/initDBuser", async (req, res) => { try { const loggedUser = String(req.user?.name ?? "").trim(); if (loggedUser !== "Admin") { return res.status(403).json({ ok: false, error: "Forbidden", loggedUser, }); } const targetUser = String( req.body?.user || req.query?.user || req.body?.name || req.query?.name || "", ).trim(); if (!targetUser) { return res.status(400).json({ ok: false, error: "Missing user", }); } console.log( `🧹 [POST /activities/initDBuser] ` + `Admin=${loggedUser} target=${targetUser}`, ); const result = await db.transaction(async (trx) => { const deletedActivities = await trx("activities") .where({ user: targetUser }) .del(); const deletedHardRecords = await trx("deleted_hard_activities") .where({ user: targetUser }) .del(); const deletedSyncRecords = await trx("sync_done_activities") .where({ device_id: targetUser }) .del(); return { deletedActivities, deletedHardRecords, deletedSyncRecords, }; }); return res.json({ ok: true, user: targetUser, ...result, message: `DB attività svuotato per user=${targetUser}`, }); } catch (err) { console.error("❌ Errore POST /activities/initDBuser:", err); return res.status(500).json({ ok: false, error: err.message, }); } }); // ----------------------------------------------------- // GET /activities/deleted_hard // Deve stare PRIMA di /:id // ----------------------------------------------------- router.get("/deleted_hard", async (req, res) => { const access = getActivityReadAccess(req, res); if (!access) { return; } const since = req.query.since; if (!since) { return res.status(400).json({ error: "Missing ?since=timestamp", }); } if (access.readableSources.length === 0) { return res.json({ deleted: [], }); } try { const rows = await db("deleted_hard_activities") .whereIn("user", access.readableSources) .andWhere("deleted_at", ">", since) .orderBy("deleted_at", "asc"); return res.json({ deleted: rows, }); } catch (err) { console.error("❌ Errore GET /activities/deleted_hard:", err); return res.status(500).json({ error: err.message, }); } }); // ----------------------------------------------------- // POST /activities/sync_done // ----------------------------------------------------- router.post("/sync_done", async (req, res) => { try { const loggedUser = String(req.user?.name ?? "").trim(); const deviceId = String(req.body?.device_id ?? "").trim(); const sessionId = String(req.body?.session_id ?? "").trim(); if (!loggedUser) { return res.status(401).json({ ok: false, error: "Unauthorized", }); } if (!getUserAccess(loggedUser)) { return res.status(403).json({ ok: false, error: "Utente non configurato", user: loggedUser, }); } if (!deviceId) { return res.status(400).json({ ok: false, error: "Missing device_id", }); } if (!sessionId) { return res.status(400).json({ ok: false, error: "Missing session_id", }); } if (!canWrite(loggedUser, deviceId)) { return res.status(403).json({ ok: false, error: "Write not allowed", device_id: deviceId, }); } await db("sync_done_activities").insert({ session_id: sessionId, device_id: deviceId, last_sync: req.body.last_sync, }); return res.json({ ok: true, }); } catch (err) { return res.status(500).json({ ok: false, error: err.message, }); } }); // ----------------------------------------------------- // POST /activities/:id/toggle-soft // Toggle soft delete attività per ID // - Se deleted_at è null → soft delete // - Se deleted_at è valorizzato → restore // ----------------------------------------------------- router.post("/:id/toggle-soft", async (req, res) => { const id = req.params.id; const loggedUser = String(req.user?.name ?? "").trim(); const start = Date.now(); try { if (!loggedUser) { return res.status(401).json({ ok: false, error: "Unauthorized", }); } console.log( `🔁 [TOGGLE SOFT /activities/${id}] ` + `richiesto da=${loggedUser}`, ); const row = await db("activities").where({ id }).first(); if (!row || !canWrite(loggedUser, row.user)) { return res.status(404).json({ ok: false, error: "Activity not found or not allowed", id, }); } const now = new Date().toISOString(); const nowMs = Date.now(); const nextDeletedAt = row.deleted_at ? null : now; await db("activities").where({ id }).update({ deleted_at: nextDeletedAt, updated_at: now, date_modified_millis: nowMs, }); try { wss.broadcastToUser(row.user, { type: "updated", id, entity: "activity", deleted_at: nextDeletedAt, }); } catch (err) { console.warn( "⚠️ WS broadcast activity toggle soft fallito:", err.message, ); } return res.json({ ok: true, id, user: row.user, deleted_at: nextDeletedAt, soft_deleted: Boolean(nextDeletedAt), restored: !nextDeletedAt, duration_ms: Date.now() - start, }); } catch (err) { console.error("❌ Errore POST /activities/:id/toggle-soft:", err); return res.status(500).json({ ok: false, error: err.message, }); } }); // ----------------------------------------------------- // DELETE /activities/:id // HARD DELETE attività per ID // - Sposta originale in public/del_activities//original// // - Rimuove record da activities // - Registra ID in deleted_hard_activities // ----------------------------------------------------- router.delete("/:id", async (req, res) => { const id = req.params.id; const loggedUser = req.user?.name; const start = Date.now(); try { if (!loggedUser) { return res.status(401).json({ ok: false, error: "Unauthorized", }); } console.log( `🗑️ [HARD DELETE /activities/${id}] richiesto da=${loggedUser}`, ); const row = await db("activities").where({ id }).first(); if (!row || !canWrite(loggedUser, row.user)) { return res.status(404).json({ ok: false, error: "Activity not found or not allowed", id, }); } if (!row) { return res.status(404).json({ ok: false, error: "Activity not found or not allowed", id, }); } const deletedAt = new Date().toISOString(); // 1. Sposta file originale attività const originalSrc = getOriginalActivityPath(row); const originalDst = getDeletedActivityPath(row); const originalMoved = await moveFileSafe(originalSrc, originalDst); // 2. Elimina record da activities await db("activities").where({ id }).del(); // 3. Registra hard delete await db("deleted_hard_activities") .insert({ id, user: row.user, deleted_at: deletedAt, }) .onConflict("id") .merge(); // 4. WS realtime try { wss.broadcastToUser(row.user, { type: "removed", id, entity: "activity", }); } catch (e) { console.warn("⚠️ WS broadcast activity hard delete fallito:", e.message); } console.log( `✅ [HARD DELETE /activities/${id}] user=${row.user} durata=${Date.now() - start}ms`, ); res.json({ ok: true, id, user: row.user, deleted_at: deletedAt, hard_deleted: true, files: { original_src: originalSrc, original_dst: originalDst, original_moved: originalMoved, }, duration_ms: Date.now() - start, }); } catch (err) { console.error("❌ Errore HARD DELETE /activities/:id:", err); res.status(500).json({ ok: false, error: err.message, }); } }); // ----------------------------------------------------- // GET /activities/:id/route-points // Carica tutti i punti della traccia FIT su richiesta. // I punti non vengono salvati nel database. // Deve stare PRIMA di GET /activities/:id. // ----------------------------------------------------- router.get("/:id/route-points", async (req, res) => { const id = req.params.id; const loggedUser = req.user?.name; //const startedAt = Date.now(); if (!loggedUser) { return res.status(401).json({ ok: false, error: "Unauthorized", }); } try { const access = getUserAccess(loggedUser); if (!access) { return res.status(403).json({ ok: false, error: "Utente non configurato", user: loggedUser, }); } if (access.readableSources.length === 0) { return res.status(404).json({ ok: false, error: "Activity not found or not allowed", id, }); } const row = await db("activities") .where({ id }) .whereIn("user", access.readableSources) .first(); if (!row) { return res.status(404).json({ ok: false, error: "Activity not found or not allowed", id, }); } const format = String(row.format || row.file_extension || "") .toLowerCase() .replaceAll(".", "") .trim(); const fileName = String(row.file_name || "").toLowerCase(); const isFit = format === "fit" || fileName.endsWith(".fit"); if (!isFit) { return res.status(400).json({ ok: false, error: "Full route endpoint currently supports FIT activities only", id, format, }); } const originalPath = getOriginalActivityPath(row); const exists = await pathExists(originalPath); if (!exists) { console.warn( "[activity route-points] File FIT non trovato:", originalPath, ); return res.status(404).json({ ok: false, error: "Activity file not found", id, }); } const points = await parseFitTrackPoints(originalPath); /* console.log( '[activity route-points] traccia FIT caricata', { id, user: row.user, requestedBy: loggedUser, expectedPoints: row.point_count, loadedPoints: points.length, durationMs: Date.now() - startedAt } ); */ res.set("Cache-Control", "private, no-store"); return res.json({ ok: true, activity_id: row.id, format: "FIT", expected_point_count: Number(row.point_count) || null, point_count: points.length, points, }); } catch (error) { console.error("[activity route-points] Errore:", error); return res.status(500).json({ ok: false, error: "Errore caricamento traccia FIT", details: error.message, }); } }); // ----------------------------------------------------- // GET /activities/:id // Deve stare DOPO deleted_hard, changes, byIds, initDB, // sync_done, toggle-soft e delete. // ----------------------------------------------------- router.get("/:id", async (req, res) => { const access = getActivityReadAccess(req, res); if (!access) { return; } const id = req.params.id; const start = Date.now(); console.log( `📤 [GET /activities/${id}] Richiesta da=${access.name} ` + `at=${new Date().toISOString()}`, ); if (access.readableSources.length === 0) { return res.json([]); } try { const rows = await db("activities") .where({ id }) .whereIn("user", access.readableSources); console.log( `📤 [GET /activities/${id}] Risposta: ${rows.length} attività ` + `durata=${Date.now() - start}ms`, ); if (!rows.length) { return res.json([]); } return res.json(rows.map(normalizeActivity)); } catch (err) { console.error("❌ Errore GET /activities/:id:", err); return res.status(500).json({ error: err.message, }); } }); module.exports = router;