From 2e203a0d0d71dc2ff3aeebf1adafa859e3b2b1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Sch=C3=B6pe?= Date: Mon, 27 Jul 2026 13:03:28 +0200 Subject: [PATCH 1/3] Enable live tracking endpoints and scheduled data fetching --- server/controller/HomeController.ts | 12 +- server/controller/LivetrackingController.ts | 26 ++-- server/routes/index.ts | 4 +- server/service/LivetrackingService.ts | 40 ++++-- server/test/LivetrackingService.test.ts | 134 ++++++++++++++++++++ 5 files changed, 185 insertions(+), 31 deletions(-) create mode 100644 server/test/LivetrackingService.test.ts diff --git a/server/controller/HomeController.ts b/server/controller/HomeController.ts index f73c055ae..da7175fc5 100644 --- a/server/controller/HomeController.ts +++ b/server/controller/HomeController.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from "express"; import service from "../service/HomeService"; +import liveTrackingService from "../service/LivetrackingService"; import { getCache, setCache } from "./CacheManager"; const router = express.Router(); @@ -10,23 +11,22 @@ const router = express.Router(); * @route GET / */ router.get("/", async (req: Request, res: Response, next) => { - // const liveFlights = - // req.authStatus === "VALID" ? liveTrackingService.getActiveDistances() : []; + const liveFlights = + req.authStatus === "VALID" ? liveTrackingService.getActiveDistances() : []; try { const cached = getCache(req); - if (cached) return res.json(cached); - // if (cached) return res.json({ ...cached, liveFlights }); + if (cached) return res.json({ ...cached, liveFlights }); const homeData = await service.get(); setCache(req, homeData); - res.json(homeData); - // res.json({ ...homeData, liveFlights }); + res.json({ ...homeData, liveFlights }); } catch (error) { next(error); } }); module.exports = router; +export default router; diff --git a/server/controller/LivetrackingController.ts b/server/controller/LivetrackingController.ts index 89d4d1430..503ef7b60 100644 --- a/server/controller/LivetrackingController.ts +++ b/server/controller/LivetrackingController.ts @@ -1,19 +1,21 @@ -import express from "express"; +import express, { NextFunction, Request, Response } from "express"; +import liveTrackingService from "../service/LivetrackingService"; +import { requesterMustBeLoggedIn } from "./Auth"; const router = express.Router(); // @desc Gets the live tracking data // @route GET /live -// router.get( -// "/", -// requesterMustBeLoggedIn, -// async (_: Request, res: Response, next: NextFunction) => { -// try { -// res.json(liveTrackingService.getActive()); -// } catch (error) { -// next(error); -// } -// } -// ); +router.get( + "/", + requesterMustBeLoggedIn, + async (_: Request, res: Response, next: NextFunction) => { + try { + res.json(liveTrackingService.getActive()); + } catch (error) { + next(error); + } + } +); export default router; diff --git a/server/routes/index.ts b/server/routes/index.ts index 621e5e308..27368c7ac 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -19,7 +19,7 @@ import GeneralController from "../controller/GeneralController"; import MailController from "../controller/MailController"; import SiteController from "../controller/SiteController"; import CacheController from "../controller/CacheController"; -// import LivetrackingController from "../controller/LivetrackingController"; +import LivetrackingController from "../controller/LivetrackingController"; import TestDataController from "../controller/TestDataController"; import ImportDataController from "../controller/ImportDataController"; @@ -39,7 +39,7 @@ router.use("/media", MediaController); router.use("/general", GeneralController); router.use("/mail", MailController); router.use("/sites", SiteController); -// router.use("/live", LivetrackingController); +router.use("/live", LivetrackingController); router.use("/cache", CacheController); if (config.get("env") !== "production" || config.get("overruleActive")) { diff --git a/server/service/LivetrackingService.ts b/server/service/LivetrackingService.ts index 4590458fd..a6c200670 100644 --- a/server/service/LivetrackingService.ts +++ b/server/service/LivetrackingService.ts @@ -1,5 +1,6 @@ import axios from "axios"; import NodeCache from "node-cache"; +import cron from "node-cron"; import sequelize from "sequelize"; import config from "../config/env-config"; import logger from "../config/logger"; @@ -13,6 +14,7 @@ const IGC_XC_SCORE_HOST = config.get("igcXcScoreHost"); const CACHE_INDEFINITELY = 0; const CACHE_10_MINUTES = 600; +const EXTERNAL_REQUEST_TIMEOUT_MS = 20_000; const cache = new NodeCache({ stdTTL: CACHE_10_MINUTES }); @@ -47,9 +49,12 @@ const TRACK_RESOLUTION = 10; // 10 seconds (s not ms) const TRACK_RESOLUTION_HOME = 60; // 60 seconds (s not ms) // Run the job every 60 seconds between 8 in the morning and 9 in the evening -// TODO: Re-enable cron job for live tracking: Skipped for now due to errors on server -// const task = cron.schedule("* 8-21 * * *", fetchFlarmData); -// task.start(); +const task = cron.schedule("* 8-21 * * *", fetchFlarmData, { + noOverlap: true, + timezone: config.get("timezone"), +}); + +logger.info("LT: Live tracking cron job enabled"); const service = { getActive: () => cache.get("flarm") || [], @@ -90,10 +95,17 @@ async function fetchFlarmData() { const idsWithNames = await getUserFlarmIds(); const ids = idsWithNames.map((user) => user.flarmId); + if (ids.length === 0) { + logger.debug("LT: No FLARM IDs configured"); + cache.set("flarm", []); + return; + } + const url = `${FLARM_URL}/${ids.join(",")}`; const { data } = await axios.get(url, { headers: { "API-Key": FLARM_API_KEY }, + timeout: EXTERNAL_REQUEST_TIMEOUT_MS, }); logger.debug(`LT: Active FLARM IDs: ${Object.keys(data.tracks)}`); @@ -139,7 +151,8 @@ async function calculateLiveTrackDistance(fixes: FlightData[]) { try { const { data } = await axios.post( `http://${IGC_XC_SCORE_HOST}:3030`, - transformedFixes + transformedFixes, + { timeout: EXTERNAL_REQUEST_TIMEOUT_MS } ); if (typeof data === "number") return data; @@ -162,7 +175,7 @@ function reduceResolution(array: FlightData[], resolution: number) { } type FlarmIdWithName = { - flarmId: string | null | undefined; + flarmId: string; name: string; }; @@ -176,12 +189,17 @@ async function getUserFlarmIds(): Promise { attributes: ["flarmId", "firstName", "lastName"], }); - const arrangedIds = ids.map((user) => { - return { - flarmId: user.flarmId, - name: `${user.firstName} ${user.lastName}`, - }; - }); + const arrangedIds = ids + .filter( + (user): user is typeof user & { flarmId: string } => + typeof user.flarmId === "string" && user.flarmId.length === 6 + ) + .map((user) => { + return { + flarmId: user.flarmId, + name: `${user.firstName} ${user.lastName}`, + }; + }); cache.set(FLARM_USER_IDS_KEY, arrangedIds, CACHE_INDEFINITELY); return arrangedIds; } diff --git a/server/test/LivetrackingService.test.ts b/server/test/LivetrackingService.test.ts new file mode 100644 index 000000000..071218aba --- /dev/null +++ b/server/test/LivetrackingService.test.ts @@ -0,0 +1,134 @@ +const mockSchedule = jest.fn(); +const mockFindAll = jest.fn(); +const mockAxiosGet = jest.fn(); +const mockAxiosPost = jest.fn(); + +jest.mock("node-cron", () => ({ + __esModule: true, + default: { schedule: mockSchedule }, +})); + +jest.mock("axios", () => ({ + __esModule: true, + default: { + get: mockAxiosGet, + post: mockAxiosPost, + }, +})); + +jest.mock("../db", () => ({ + __esModule: true, + default: { + User: { findAll: mockFindAll }, + }, +})); + +jest.mock("../config/logger", () => ({ + __esModule: true, + default: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }, +})); + +jest.mock("../config/env-config", () => ({ + __esModule: true, + default: { + get: jest.fn((key: string) => { + const values: Record = { + flarmUrl: "https://flarm.example/flarm", + flarmApiKey: "test-key", + igcXcScoreHost: "igc-xc-score", + timezone: "Europe/Berlin", + }; + return values[key]; + }), + }, +})); + +import liveTrackingService from "../service/LivetrackingService"; + +type ScheduledCallback = () => Promise; + +test("fetches, scores, and caches configured live tracks", async () => { + expect(mockSchedule).toHaveBeenCalledWith( + "* 8-21 * * *", + expect.any(Function), + { + noOverlap: true, + timezone: "Europe/Berlin", + } + ); + + mockFindAll.mockResolvedValue([ + { flarmId: "", firstName: "Ignored", lastName: "Pilot" }, + { flarmId: "ABC123", firstName: "Live", lastName: "Pilot" }, + ]); + + const fixes = Array.from({ length: 8 }, (_, index) => ({ + address: "ABC123", + timestamp: 1_700_000_000 + index * 10, + altitude: 500 + index, + lon: 7 + index / 100, + lat: 50 + index / 100, + })); + + mockAxiosGet.mockResolvedValue({ + data: { + tracks: { ABC123: fixes }, + distances: {}, + }, + }); + mockAxiosPost.mockResolvedValue({ data: 42.5 }); + + const scheduledFetch = mockSchedule.mock.calls[0][1] as ScheduledCallback; + await scheduledFetch(); + + expect(mockAxiosGet).toHaveBeenCalledWith( + "https://flarm.example/flarm/ABC123", + { + headers: { "API-Key": "test-key" }, + timeout: 20_000, + } + ); + expect(mockAxiosPost).toHaveBeenCalledWith( + "http://igc-xc-score:3030", + expect.arrayContaining([ + expect.objectContaining({ + timestamp: fixes[0].timestamp, + latitude: fixes[0].lat, + longitude: fixes[0].lon, + valid: true, + }), + ]), + { timeout: 20_000 } + ); + expect(liveTrackingService.getActive()).toEqual([ + { + name: "Live Pilot", + distance: 42.5, + fixes, + }, + ]); + expect(liveTrackingService.getActiveDistances()).toEqual([ + { + user: { fullName: "Live Pilot" }, + flightDistance: 42.5, + id: "ABC123", + isLive: true, + track: [ + { + lat: fixes[0].lat, + long: fixes[0].lon, + timestamp: fixes[0].timestamp * 1000, + }, + { + lat: fixes[6].lat, + long: fixes[6].lon, + timestamp: fixes[6].timestamp * 1000, + }, + ], + }, + ]); +}); From 683298dc8aca3c422cbcd8d8596baeeef3787db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Sch=C3=B6pe?= Date: Mon, 27 Jul 2026 13:05:45 +0200 Subject: [PATCH 2/3] Add configuration to disable live tracking --- server/.env-sample | 7 ++++++- server/.env.ci | 1 + server/config/env-config.ts | 6 ++++++ server/service/LivetrackingService.ts | 16 ++++++++++------ server/test/LivetrackingService.test.ts | 19 ++++++++++++++++++- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/server/.env-sample b/server/.env-sample index 774b30a76..c59efae27 100644 --- a/server/.env-sample +++ b/server/.env-sample @@ -40,5 +40,10 @@ PGADMIN_DEFAULT_PASSWORD=P@ssw0rd # Sentry SENTRY_URL= +# Live tracking +LIVE_TRACKING_ENABLED=true +FLARM_URL=https://flarm.xccup.net/flarm +FLARM_API_KEY= + # Live scoring API -IGC_XC_SCORE_HOST=igc-xc-score \ No newline at end of file +IGC_XC_SCORE_HOST=igc-xc-score diff --git a/server/.env.ci b/server/.env.ci index d205540ea..a87d870f9 100644 --- a/server/.env.ci +++ b/server/.env.ci @@ -4,6 +4,7 @@ SERVER_LOG_LEVEL=debug DISABLE_API_PROTECTION=true SERVER_IMPORT_TEST_DATA=true +LIVE_TRACKING_ENABLED=false JWT_LOGIN_TOKEN=bfd2419fb9a02e6b5ee15f4849df10c1b98426c77292f030f04d8a01f4c293a31ba9b6dcf9ad37ef6220a461db11d3e180b85afee58abe94be204b086ca6f01c JWT_REFRESH_TOKEN=1430de12edb529a469d5cc91d4f4b2d4495cfe72664df4e7fd1c8ce9f4dc21800e7b8ace49765a98cdad48ff7b4193736e3158c85a9c459a71822ea090a89980 diff --git a/server/config/env-config.ts b/server/config/env-config.ts index 590e90e88..875fe8075 100644 --- a/server/config/env-config.ts +++ b/server/config/env-config.ts @@ -108,6 +108,12 @@ const config = convict({ default: "qwertz12345", env: "FLARM_API_KEY", }, + liveTrackingEnabled: { + doc: "Whether live FLARM tracking is enabled", + format: Boolean, + default: true, + env: "LIVE_TRACKING_ENABLED", + }, jwtLogin: { doc: "The login token for the jwt authentication mechanism", format: check128Hex, diff --git a/server/service/LivetrackingService.ts b/server/service/LivetrackingService.ts index a6c200670..58a665729 100644 --- a/server/service/LivetrackingService.ts +++ b/server/service/LivetrackingService.ts @@ -8,6 +8,7 @@ import db from "../db"; const FLARM_URL = config.get("flarmUrl"); const FLARM_API_KEY = config.get("flarmApiKey"); +const LIVE_TRACKING_ENABLED = config.get("liveTrackingEnabled"); const FLARM_USER_IDS_KEY = "user-flarm-ids"; const IGC_XC_SCORE_HOST = config.get("igcXcScoreHost"); @@ -49,12 +50,15 @@ const TRACK_RESOLUTION = 10; // 10 seconds (s not ms) const TRACK_RESOLUTION_HOME = 60; // 60 seconds (s not ms) // Run the job every 60 seconds between 8 in the morning and 9 in the evening -const task = cron.schedule("* 8-21 * * *", fetchFlarmData, { - noOverlap: true, - timezone: config.get("timezone"), -}); - -logger.info("LT: Live tracking cron job enabled"); +if (LIVE_TRACKING_ENABLED) { + cron.schedule("* 8-21 * * *", fetchFlarmData, { + noOverlap: true, + timezone: config.get("timezone"), + }); + logger.info("LT: Live tracking cron job enabled"); +} else { + logger.info("LT: Live tracking disabled by configuration"); +} const service = { getActive: () => cache.get("flarm") || [], diff --git a/server/test/LivetrackingService.test.ts b/server/test/LivetrackingService.test.ts index 071218aba..8a85db8c9 100644 --- a/server/test/LivetrackingService.test.ts +++ b/server/test/LivetrackingService.test.ts @@ -2,6 +2,7 @@ const mockSchedule = jest.fn(); const mockFindAll = jest.fn(); const mockAxiosGet = jest.fn(); const mockAxiosPost = jest.fn(); +let mockLiveTrackingEnabled = true; jest.mock("node-cron", () => ({ __esModule: true, @@ -36,9 +37,10 @@ jest.mock("../config/env-config", () => ({ __esModule: true, default: { get: jest.fn((key: string) => { - const values: Record = { + const values: Record = { flarmUrl: "https://flarm.example/flarm", flarmApiKey: "test-key", + liveTrackingEnabled: mockLiveTrackingEnabled, igcXcScoreHost: "igc-xc-score", timezone: "Europe/Berlin", }; @@ -132,3 +134,18 @@ test("fetches, scores, and caches configured live tracks", async () => { }, ]); }); + +test("does not schedule collection when live tracking is disabled", () => { + mockSchedule.mockClear(); + mockLiveTrackingEnabled = false; + + jest.isolateModules(() => { + const disabledModule = require("../service/LivetrackingService"); + const disabledService = disabledModule.default ?? disabledModule; + + expect(disabledService.getActive()).toEqual([]); + }); + + expect(mockSchedule).not.toHaveBeenCalled(); + mockLiveTrackingEnabled = true; +}); From 0d99136e8f5a7261cc9d20a2a2022cc316112f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Sch=C3=B6pe?= Date: Wed, 29 Jul 2026 10:01:01 +0200 Subject: [PATCH 3/3] Fix live tracking merge export --- server/controller/HomeController.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/controller/HomeController.ts b/server/controller/HomeController.ts index 7dc218dc6..46b6bea0d 100644 --- a/server/controller/HomeController.ts +++ b/server/controller/HomeController.ts @@ -29,5 +29,3 @@ router.get("/", async (req: Request, res: Response, next) => { }); export default router; -module.exports = router; -export default router;