1270 lines
31 KiB
JavaScript
1270 lines
31 KiB
JavaScript
// 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 {
|
|
searchActivitiesInBounds,
|
|
invalidateActivitySpatialIndex,
|
|
} = require("../helpers/activitySpatialIndex");
|
|
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/<user>/original/<cartella>/
|
|
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();
|
|
|
|
invalidateActivitySpatialIndex();
|
|
|
|
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/map
|
|
// Restituisce soltanto le attività che intersecano l'area visibile.
|
|
// Non carica i file originali: restituisce metadati e track_preview.
|
|
// -----------------------------------------------------
|
|
router.get("/map", async (req, res) => {
|
|
const access = getActivityReadAccess(req, res);
|
|
if (!access) return;
|
|
|
|
if (!access.readableSources.length) {
|
|
return res.json({ items: [], limit: 1500, truncated: false });
|
|
}
|
|
|
|
const west = Number(req.query.west);
|
|
const east = Number(req.query.east);
|
|
const south = Number(req.query.south);
|
|
const north = Number(req.query.north);
|
|
|
|
if (
|
|
![west, east, south, north].every(Number.isFinite) ||
|
|
west < -180 || west > 180 ||
|
|
east < -180 || east > 180 ||
|
|
south < -90 || north > 90 ||
|
|
south > north
|
|
) {
|
|
return res.status(400).json({
|
|
error: "Limiti geografici non validi",
|
|
});
|
|
}
|
|
|
|
const requestedLimit = Number.parseInt(req.query.limit, 10);
|
|
const limit = Number.isFinite(requestedLimit)
|
|
? Math.max(1, Math.min(requestedLimit, 2500))
|
|
: 1500;
|
|
|
|
const includeDeleted = ["1", "true", "yes"].includes(
|
|
String(req.query.showDeleted || "").toLowerCase()
|
|
);
|
|
|
|
try {
|
|
const { items: page, truncated } =
|
|
await searchActivitiesInBounds({
|
|
west,
|
|
east,
|
|
south,
|
|
north,
|
|
readableSources: access.readableSources,
|
|
includeDeleted,
|
|
limit,
|
|
});
|
|
|
|
return res.json({
|
|
items: page.map(normalizeActivity),
|
|
limit,
|
|
truncated,
|
|
});
|
|
} catch (err) {
|
|
console.error("Errore GET /activities/map:", err);
|
|
return res.status(500).json({
|
|
error: "Errore caricamento attività della mappa",
|
|
});
|
|
}
|
|
});
|
|
|
|
// GET /activities/gallery
|
|
// Paginazione dedicata alla galleria; non modifica il full sync.
|
|
// -----------------------------------------------------
|
|
router.get("/gallery", async (req, res) => {
|
|
const access = getActivityReadAccess(req, res);
|
|
if (!access) return;
|
|
|
|
if (!access.readableSources.length) {
|
|
return res.json({
|
|
items: [],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const requestedLimit = Number.parseInt(req.query.limit, 10);
|
|
const limit = Number.isFinite(requestedLimit)
|
|
? Math.max(1, Math.min(requestedLimit, 100))
|
|
: 40;
|
|
|
|
const sort = String(req.query.sort || "desc").toLowerCase() === "asc"
|
|
? "asc"
|
|
: "desc";
|
|
|
|
let cursor = null;
|
|
const cursorText = String(req.query.cursor || "");
|
|
|
|
if (cursorText) {
|
|
try {
|
|
const decoded = JSON.parse(
|
|
Buffer.from(cursorText, "base64url").toString("utf8")
|
|
);
|
|
|
|
if (
|
|
!decoded ||
|
|
!Number.isFinite(decoded.startTime) ||
|
|
typeof decoded.id !== "string" ||
|
|
!decoded.id
|
|
) {
|
|
return res.status(400).json({
|
|
error: "Cursore non valido",
|
|
});
|
|
}
|
|
|
|
cursor = decoded;
|
|
} catch {
|
|
return res.status(400).json({
|
|
error: "Cursore non valido",
|
|
});
|
|
}
|
|
}
|
|
|
|
let query = db("activities")
|
|
.whereIn("user", access.readableSources);
|
|
|
|
const includeDeleted = ["1", "true", "yes"].includes(
|
|
String(req.query.showDeleted || "").toLowerCase()
|
|
);
|
|
|
|
if (!includeDeleted) {
|
|
query = query.whereNull("deleted_at");
|
|
}
|
|
|
|
// Ordinamento stabile: data di inizio, poi ID.
|
|
// COALESCE include anche attività senza data.
|
|
if (cursor) {
|
|
const comparison = sort === "asc" ? ">" : "<";
|
|
|
|
query = query.where(function () {
|
|
this.whereRaw(
|
|
`COALESCE(start_time_millis, 0) ${comparison} ?`,
|
|
[cursor.startTime]
|
|
).orWhere(function () {
|
|
this.whereRaw(
|
|
"COALESCE(start_time_millis, 0) = ?",
|
|
[cursor.startTime]
|
|
).andWhere("id", comparison, cursor.id);
|
|
});
|
|
});
|
|
}
|
|
|
|
const direction = sort === "asc" ? "ASC" : "DESC";
|
|
const rows = await query
|
|
.orderByRaw(`COALESCE(start_time_millis, 0) ${direction}`)
|
|
.orderBy("id", sort)
|
|
.limit(limit + 1);
|
|
|
|
const hasMore = rows.length > limit;
|
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
|
|
let nextCursor = null;
|
|
|
|
if (hasMore && page.length) {
|
|
const last = page[page.length - 1];
|
|
|
|
nextCursor = Buffer.from(JSON.stringify({
|
|
startTime: Number(last.start_time_millis) || 0,
|
|
id: String(last.id),
|
|
})).toString("base64url");
|
|
}
|
|
|
|
return res.json({
|
|
items: page.map(normalizeActivity),
|
|
nextCursor,
|
|
hasMore,
|
|
limit,
|
|
});
|
|
} catch (err) {
|
|
console.error("Errore GET /activities/gallery:", err);
|
|
|
|
return res.status(500).json({
|
|
error: "Errore caricamento galleria attività",
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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,
|
|
};
|
|
});
|
|
|
|
invalidateActivitySpatialIndex();
|
|
|
|
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,
|
|
};
|
|
});
|
|
|
|
invalidateActivitySpatialIndex();
|
|
|
|
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,
|
|
});
|
|
|
|
invalidateActivitySpatialIndex();
|
|
|
|
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/<user>/original/<cartella>/<attività>
|
|
// - 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();
|
|
|
|
invalidateActivitySpatialIndex();
|
|
|
|
// 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;
|