Skip to content

Commit 3ef2548

Browse files
Merge pull request #96 from sebastiankrll/feature/events-and-bookings
Feature/events and bookings
2 parents 37afb19 + 7486126 commit 3ef2548

82 files changed

Lines changed: 4012 additions & 2582 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/routes/data.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { rdsGetSingle } from "@sr24/db/redis";
22
import type { FastifyPluginAsync } from "fastify";
33
import { getFlightsByCallsign, getFlightsByRegistration, getPilotReplay } from "../services/db.js";
4+
import { bookingsStore } from "../stores/bookings.js";
5+
import { getDataVersions } from "../stores/static.js";
46

57
const dataRoutes: FastifyPluginAsync = async (app) => {
8+
app.get("/static/versions", async () => {
9+
return await getDataVersions();
10+
});
11+
612
app.get(
713
"/flights/callsign/:callsign",
814
{
@@ -96,6 +102,10 @@ const dataRoutes: FastifyPluginAsync = async (app) => {
96102
return aircraft;
97103
},
98104
);
105+
106+
app.get("/bookings", async () => {
107+
return bookingsStore.bookings;
108+
});
99109
};
100110

101111
export default dataRoutes;

apps/api/src/routes/map.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,25 +131,20 @@ const mapRoutes: FastifyPluginAsync = async (app) => {
131131
);
132132

133133
app.get(
134-
"/controller/:callsigns",
134+
"/controller/:type/:callsign",
135135
{
136136
schema: {
137137
params: {
138138
type: "object",
139-
properties: { callsigns: { type: "string", minLength: 4 } },
140-
required: ["callsigns"],
139+
properties: { type: { type: "string", enum: ["airport", "sector"] }, callsign: { type: "string", minLength: 3 } },
140+
required: ["type", "callsign"],
141141
},
142142
},
143143
},
144144
async (request) => {
145-
const { callsigns } = request.params as { callsigns: string };
146-
const callsignArray = callsigns.split(",");
145+
const { type, callsign } = request.params as { type: "airport" | "sector"; callsign: string };
147146

148-
if (callsignArray.length === 0) {
149-
throw app.httpErrors.badRequest({ error: "At least one callsign is required" });
150-
}
151-
152-
return callsignArray.map((callsign) => mapStore.controllers.get(callsign) || null).filter((controller) => controller !== null);
147+
return mapStore.getControllersByCallsign(callsign, type);
153148
},
154149
);
155150
};

apps/api/src/services/db.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function getPilotsByAirport(icao: string, direction?: string, limit
1919
where[timeCol] = { gte: new Date() };
2020
}
2121

22-
return await prisma.pilot.findMany({
22+
let pilots = await prisma.pilot.findMany({
2323
take: normalizedBackwards ? -(normalizedLimit + 1) : normalizedLimit + 1,
2424
skip: cursor ? 1 : 0,
2525
cursor: cursor
@@ -40,6 +40,28 @@ export async function getPilotsByAirport(icao: string, direction?: string, limit
4040
live: true,
4141
},
4242
});
43+
44+
if (!cursor && pilots.length === 0) {
45+
pilots = await prisma.pilot.findMany({
46+
take: 5,
47+
where: {
48+
[dirCol]: icao.toUpperCase(),
49+
[timeCol]: { lt: new Date() },
50+
},
51+
orderBy: { [timeCol]: "desc" },
52+
select: {
53+
id: true,
54+
callsign: true,
55+
aircraft: true,
56+
flight_plan: true,
57+
times: true,
58+
live: true,
59+
},
60+
});
61+
pilots.reverse();
62+
}
63+
64+
return pilots;
4365
}
4466

4567
async function searchPilots(where: Prisma.PilotWhereInput) {

apps/api/src/stores/bookings.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { rdsSub } from "@sr24/db/redis";
2+
import type { Booking } from "@sr24/types/interface";
3+
4+
class BookingsStore {
5+
bookings: Booking[] = [];
6+
7+
async start() {
8+
await rdsSub("data:bookings", (data) => {
9+
const parsed: Booking[] = JSON.parse(data);
10+
this.bookings = parsed;
11+
});
12+
}
13+
}
14+
15+
export const bookingsStore = new BookingsStore();
16+
await bookingsStore.start();

apps/api/src/stores/static.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { rdsGetSingle } from "@sr24/db/redis";
2+
3+
const CACHE_DURATION = 15 * 60 * 1000;
4+
5+
type StaticVersions = {
6+
airportsVersion: string;
7+
traconsVersion: string;
8+
firsVersion: string;
9+
airlinesVersion: string;
10+
aircraftsVersion: string;
11+
};
12+
13+
let versionsCache: StaticVersions | null = null;
14+
let cacheTimestamp = 0;
15+
16+
export async function getDataVersions(): Promise<StaticVersions> {
17+
const now = Date.now();
18+
if (versionsCache && now - cacheTimestamp < CACHE_DURATION) {
19+
return versionsCache;
20+
}
21+
22+
const airlines = await rdsGetSingle("static_airlines:version");
23+
const aircrafts = await rdsGetSingle("static_aircrafts:version");
24+
const airports = await rdsGetSingle("static_airports:version");
25+
const firs = await rdsGetSingle("static_firs:version");
26+
const tracons = await rdsGetSingle("static_tracons:version");
27+
28+
const versions: StaticVersions = {
29+
airlinesVersion: airlines || "unknown",
30+
aircraftsVersion: aircrafts || "unknown",
31+
airportsVersion: airports || "unknown",
32+
firsVersion: firs || "unknown",
33+
traconsVersion: tracons || "unknown",
34+
};
35+
versionsCache = versions;
36+
cacheTimestamp = now;
37+
38+
return versions;
39+
}

apps/api/src/stores/vatsim.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { rdsSub } from "@sr24/db/redis";
2-
import type { AirportLong, ControllerLong, DashboardData, InitialData, PilotLong, RedisAll } from "@sr24/types/interface";
2+
import type { AirportLong, ControllerLong, ControllerMerged, DashboardData, InitialData, PilotLong, RedisAll } from "@sr24/types/interface";
33

44
class MapStore {
55
init: InitialData | null = null;
66
dashboard: DashboardData | null = null;
77
pilots = new Map<string, PilotLong>();
88
controllers = new Map<string, ControllerLong>();
99
airports = new Map<string, AirportLong>();
10+
merged: ControllerMerged[] = [];
1011

1112
async start() {
1213
await rdsSub("data:all", (data) => {
@@ -28,8 +29,25 @@ class MapStore {
2829
parsed.airports.forEach((a) => {
2930
this.airports.set(a.icao, a);
3031
});
32+
33+
this.merged = parsed.init.controllers;
3134
});
3235
}
36+
37+
getControllersByCallsign(callsign: string, type: "airport" | "sector"): ControllerLong[] {
38+
if (type === "airport") {
39+
const ids = this.merged.filter((c) => c.id === `airport_${callsign}`).flatMap((c) => c.controllers.map((ctl) => ctl.callsign));
40+
return ids.map((id) => this.controllers.get(id)).filter((controller) => controller !== undefined);
41+
}
42+
43+
const firIds = this.merged.filter((c) => c.id === `fir_${callsign}`).flatMap((c) => c.controllers.map((ctl) => ctl.callsign));
44+
if (firIds.length > 0) {
45+
return firIds.map((id) => this.controllers.get(id)).filter((controller) => controller !== undefined);
46+
} else {
47+
const ids = this.merged.filter((c) => c.id === `tracon_${callsign}`).flatMap((c) => c.controllers.map((ctl) => ctl.callsign));
48+
return ids.map((id) => this.controllers.get(id)).filter((controller) => controller !== undefined);
49+
}
50+
}
3351
}
3452

3553
export const mapStore = new MapStore();

apps/ingestion/src/bookings.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { rdsPub } from "@sr24/db/redis";
2+
import type { Booking } from "@sr24/types/interface";
3+
import type { VatsimBooking } from "@sr24/types/vatsim";
4+
import axios from "axios";
5+
import { findPrefixMatch, parseAirportFacility, reduceCallsign } from "./utils/sectors.js";
6+
7+
const UPDATE_INTERVAL = 10 * 60 * 1000;
8+
let lastUpdate = 0;
9+
10+
export async function updateBookingsData(): Promise<void> {
11+
const now = Date.now();
12+
if (now - lastUpdate < UPDATE_INTERVAL) return;
13+
14+
const bookings = await axios.get<VatsimBooking[]>("https://atc-bookings.vatsim.net/api/booking").then((res) => res.data);
15+
const parsedBookings = parseBookings(bookings);
16+
17+
const twoDaysFromNow = now + 2 * 24 * 60 * 60 * 1000;
18+
const limitedBookings = parsedBookings.filter((booking) => {
19+
const bookingStart = new Date(booking.start).getTime();
20+
return bookingStart <= twoDaysFromNow;
21+
});
22+
23+
const sortedBookings = limitedBookings.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
24+
25+
rdsPub("data:bookings", sortedBookings);
26+
lastUpdate = now;
27+
}
28+
29+
function parseBookings(bookings: VatsimBooking[]): Booking[] {
30+
const parsed: Booking[] = [];
31+
32+
for (const booking of bookings) {
33+
let id: string | null = null;
34+
let facility: Booking["facility"] | null = null;
35+
36+
const callsign = booking.callsign.toUpperCase();
37+
const levels = reduceCallsign(callsign);
38+
39+
if (callsign.endsWith("_TWR") || callsign.endsWith("_GND") || callsign.endsWith("_DEL") || callsign.endsWith("_ATIS")) {
40+
id = levels[levels.length - 1];
41+
facility = parseAirportFacility(callsign);
42+
} else if (callsign.endsWith("_APP") || callsign.endsWith("_DEP")) {
43+
id = findPrefixMatch(levels, 5);
44+
if (!id) {
45+
id = levels[levels.length - 1];
46+
}
47+
facility = 5;
48+
} else {
49+
id = findPrefixMatch(levels, 6);
50+
facility = 6;
51+
}
52+
53+
if (!id || facility === null) continue;
54+
55+
const bookingEntry: Booking = {
56+
id,
57+
facility,
58+
callsign: booking.callsign,
59+
type: booking.type,
60+
start: booking.start,
61+
end: booking.end,
62+
};
63+
parsed.push(bookingEntry);
64+
}
65+
66+
return parsed;
67+
}

apps/ingestion/src/controller.ts

Lines changed: 2 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { rdsGetSingle } from "@sr24/db/redis";
2-
import type { FIRFeature, SimAwareTraconFeature } from "@sr24/types/db";
31
import type { ControllerDelta, ControllerLong, ControllerMerged, ControllerShort, PilotLong } from "@sr24/types/interface";
42
import type { VatsimData } from "@sr24/types/vatsim";
53
import { haversineDistance } from "./utils/helpers.js";
4+
import { findPrefixMatch, reduceCallsign } from "./utils/sectors.js";
65

76
let cached: ControllerMerged[] = [];
87
let updated: ControllerMerged[] = [];
@@ -12,6 +11,7 @@ export async function mapControllers(vatsimData: VatsimData, pilotsLong: PilotLo
1211
const controllersLong: ControllerLong[] = vatsimData.controllers
1312
.map((controller) => {
1413
if (controller.facility === 0 && !controller.callsign.includes("OBS")) return null;
14+
if (controller.frequency === "199.998") return null;
1515
return {
1616
callsign: controller.callsign,
1717
frequency: parseFrequencyToKHz(controller.frequency),
@@ -169,35 +169,9 @@ function getConnectionsCount(vatsimData: VatsimData, controllersLong: Controller
169169
}
170170
}
171171

172-
const firPrefixes: Map<string, string> = new Map();
173-
const traconPrefixes: Map<string, string> = new Map();
174-
175172
async function mergeControllers(controllersLong: ControllerLong[]): Promise<ControllerMerged[]> {
176-
await updateFeaturesFromRedis();
177-
178173
const merged = new Map<string, ControllerMerged>();
179174

180-
const reduceCallsign = (callsign: string): string[] => {
181-
const parts = callsign.split("_");
182-
const levels: string[] = [];
183-
184-
for (let i = parts.length; i > 0; i--) {
185-
levels.push(parts.slice(0, i).join("_"));
186-
}
187-
188-
return levels;
189-
};
190-
191-
const findPrefixMatch = (levels: string[], facility: number): string | null => {
192-
const lookup = facility === 6 ? firPrefixes : traconPrefixes;
193-
194-
for (const lvl of levels) {
195-
const match = lookup.get(lvl);
196-
if (match) return match;
197-
}
198-
return null;
199-
};
200-
201175
for (const c of controllersLong) {
202176
let id: string | null = null;
203177
let facility: ControllerMerged["facility"] | null = null;
@@ -248,49 +222,3 @@ async function mergeControllers(controllersLong: ControllerLong[]): Promise<Cont
248222

249223
return Array.from(merged.values());
250224
}
251-
252-
let currentFirsVersion: string | null = null;
253-
let currentTraconsVersion: string | null = null;
254-
255-
async function updateFeaturesFromRedis(): Promise<void> {
256-
const firsVersion = await rdsGetSingle("static_firs:version");
257-
const traconsVersion = await rdsGetSingle("static_tracons:version");
258-
259-
if (currentFirsVersion !== firsVersion) {
260-
const features = (await rdsGetSingle("static_firs:all")) as FIRFeature[] | undefined;
261-
if (features) {
262-
firPrefixes.clear();
263-
features.forEach((f) => {
264-
const prefix = f.properties.callsign_prefix;
265-
const id = f.properties.id;
266-
if (prefix === "") {
267-
firPrefixes.set(id, id);
268-
} else {
269-
firPrefixes.set(prefix, id);
270-
}
271-
});
272-
273-
currentFirsVersion = firsVersion;
274-
}
275-
}
276-
277-
if (currentTraconsVersion !== traconsVersion) {
278-
const features = (await rdsGetSingle("static_tracons:all")) as SimAwareTraconFeature[] | undefined;
279-
if (features) {
280-
traconPrefixes.clear();
281-
features.forEach((f) => {
282-
const prefixes = f.properties.prefix;
283-
284-
if (typeof prefixes === "string") {
285-
traconPrefixes.set(prefixes, f.properties.id);
286-
} else {
287-
prefixes.forEach((prefix) => {
288-
traconPrefixes.set(prefix, f.properties.id);
289-
});
290-
}
291-
});
292-
293-
currentTraconsVersion = traconsVersion;
294-
}
295-
}
296-
}

apps/ingestion/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import type { InitialData, RedisAll, WsDelta } from "@sr24/types/interface";
55
import type { VatsimData, VatsimTransceivers } from "@sr24/types/vatsim";
66
import axios from "axios";
77
import { getAirportDelta, getAirportShort, mapAirports } from "./airport.js";
8+
import { updateBookingsData } from "./bookings.js";
89
import { getControllerDelta, mapControllers } from "./controller.js";
910
import { updateDashboardData } from "./dashboard.js";
1011
import { getPilotDelta, getPilotShort, mapPilots } from "./pilot.js";
1112
import { mapTrackPoints } from "./tracks.js";
13+
import { updateSectorPrefixes } from "./utils/sectors.js";
1214

1315
const VATSIM_DATA_URL = "https://data.vatsim.net/v3/vatsim-data.json";
1416
const VATSIM_TRANSCEIVERS_URL = "https://data.vatsim.net/v3/transceivers-data.json";
@@ -35,6 +37,8 @@ async function fetchVatsimData(): Promise<void> {
3537
lastVatsimUpdate = timestmap;
3638
vatsimData.transceivers = await axios.get<VatsimTransceivers[]>(VATSIM_TRANSCEIVERS_URL).then((res) => res.data);
3739

40+
await updateSectorPrefixes();
41+
3842
const pilotsLong = await mapPilots(vatsimData);
3943
const [controllersLong, controllersMerged] = await mapControllers(vatsimData, pilotsLong);
4044
const airportsLong = await mapAirports(pilotsLong);
@@ -50,6 +54,7 @@ async function fetchVatsimData(): Promise<void> {
5054
}
5155

5256
const dashboard = await updateDashboardData(vatsimData, controllersLong);
57+
updateBookingsData();
5358

5459
const init: InitialData = {
5560
pilots: pilotsLong.map((p) => getPilotShort(p)),

0 commit comments

Comments
 (0)