Skip to content

Commit c3a4652

Browse files
committed
feat: flights page, chat design, etc...
1 parent 39e5dc7 commit c3a4652

21 files changed

Lines changed: 2608 additions & 1476 deletions

server/db/flightLogs.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { mainDb } from './connection.js';
22
import { encrypt, decrypt } from '../utils/encryption.js';
33
import { sql } from 'kysely';
44

5+
const FLIGHT_LOG_RETENTION_DAYS = 365;
6+
57
export interface FlightLogData {
68
userId: string;
79
username: string;
@@ -60,7 +62,9 @@ export async function logFlightAction(logData: FlightLogData) {
6062
}
6163
}
6264

63-
export async function cleanupOldFlightLogs(daysToKeep = 30) {
65+
export async function cleanupOldFlightLogs(
66+
daysToKeep = FLIGHT_LOG_RETENTION_DAYS
67+
) {
6468
try {
6569
const result = await mainDb
6670
.deleteFrom('flight_logs')
@@ -85,15 +89,15 @@ export function startFlightLogsCleanup() {
8589

8690
setTimeout(async () => {
8791
try {
88-
await cleanupOldFlightLogs(30);
92+
await cleanupOldFlightLogs(FLIGHT_LOG_RETENTION_DAYS);
8993
} catch (error) {
9094
console.error('Initial flight logs cleanup failed:', error);
9195
}
9296
}, 60 * 1000);
9397

9498
cleanupInterval = setInterval(async () => {
9599
try {
96-
await cleanupOldFlightLogs(30);
100+
await cleanupOldFlightLogs(FLIGHT_LOG_RETENTION_DAYS);
97101
} catch (error) {
98102
console.error('Scheduled flight logs cleanup failed:', error);
99103
}

server/db/flights.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import crypto from 'crypto';
1111
import { sql } from 'kysely';
1212
import { incrementStat } from '../utils/statisticsCache.js';
1313
import type { FlightsTable } from './types/connection/main/FlightsTable.js';
14+
import type { FlightLogsTable } from './types/connection/main/FlightLogsTable.js';
1415

1516
function createUTCDate(): Date {
1617
const now = new Date();
@@ -188,6 +189,122 @@ export async function getFlightsBySession(sessionId: string) {
188189
}
189190
}
190191

192+
export async function getFlightsByUser(userId: string) {
193+
try {
194+
const flights = await mainDb
195+
.selectFrom('flights')
196+
.selectAll()
197+
.where('user_id', '=', userId)
198+
.orderBy('created_at', 'desc')
199+
.execute();
200+
201+
return flights.map((flight) => sanitizeFlightForClient(flight));
202+
} catch (error) {
203+
console.error(`Error fetching flights for user ${userId}:`, error);
204+
return [];
205+
}
206+
}
207+
208+
export async function getFlightByIdForUser(userId: string, flightId: string) {
209+
try {
210+
const validFlightId = validateFlightId(flightId);
211+
const flight = await mainDb
212+
.selectFrom('flights')
213+
.selectAll()
214+
.where('id', '=', validFlightId)
215+
.where('user_id', '=', userId)
216+
.executeTakeFirst();
217+
218+
return flight ? sanitizeFlightForClient(flight) : null;
219+
} catch (error) {
220+
console.error(`Error fetching flight ${flightId} for user ${userId}:`, error);
221+
return null;
222+
}
223+
}
224+
225+
export async function getFlightLogsForUser(userId: string, flightId: string) {
226+
try {
227+
const validFlightId = validateFlightId(flightId);
228+
229+
const ownedFlight = await mainDb
230+
.selectFrom('flights')
231+
.select(['id', 'created_at'])
232+
.where('id', '=', validFlightId)
233+
.where('user_id', '=', userId)
234+
.executeTakeFirst();
235+
236+
if (!ownedFlight) {
237+
return { logs: [], logsDiscardedDueToAge: false };
238+
}
239+
240+
const retentionThreshold = new Date(
241+
Date.now() - 365 * 24 * 60 * 60 * 1000
242+
);
243+
const logsDiscardedDueToAge = !!(
244+
ownedFlight.created_at && ownedFlight.created_at < retentionThreshold
245+
);
246+
247+
const logs = await mainDb
248+
.selectFrom('flight_logs')
249+
.selectAll()
250+
.where('flight_id', '=', validFlightId)
251+
.orderBy('created_at', 'desc')
252+
.execute();
253+
254+
return {
255+
logs: logs.map((log: FlightLogsTable) => ({
256+
id: log.id,
257+
action: log.action,
258+
old_data: log.old_data,
259+
new_data: log.new_data,
260+
created_at: log.created_at,
261+
})),
262+
logsDiscardedDueToAge,
263+
};
264+
} catch (error) {
265+
console.error(
266+
`Error fetching flight logs for flight ${flightId} and user ${userId}:`,
267+
error
268+
);
269+
return { logs: [], logsDiscardedDueToAge: false };
270+
}
271+
}
272+
273+
export async function claimFlightForUser(
274+
sessionId: string,
275+
flightId: string,
276+
acarsToken: string,
277+
userId: string
278+
) {
279+
const validSessionId = validateSessionId(sessionId);
280+
const validFlightId = validateFlightId(flightId);
281+
282+
const flight = await mainDb
283+
.selectFrom('flights')
284+
.select(['id', 'user_id', 'acars_token'])
285+
.where('session_id', '=', validSessionId)
286+
.where('id', '=', validFlightId)
287+
.executeTakeFirst();
288+
289+
if (!flight) return { ok: false, reason: 'not_found' as const };
290+
if (flight.acars_token !== acarsToken)
291+
return { ok: false, reason: 'invalid_token' as const };
292+
if (flight.user_id && flight.user_id !== userId)
293+
return { ok: false, reason: 'already_claimed' as const };
294+
295+
await mainDb
296+
.updateTable('flights')
297+
.set({
298+
user_id: userId,
299+
updated_at: createUTCDate(),
300+
})
301+
.where('session_id', '=', validSessionId)
302+
.where('id', '=', validFlightId)
303+
.execute();
304+
305+
return { ok: true as const };
306+
}
307+
191308
export async function validateAcarsAccess(
192309
sessionId: string,
193310
flightId: string,
@@ -418,7 +535,7 @@ export async function updateFlight(
418535
for (const [key, value] of Object.entries(updates)) {
419536
let dbKey = key;
420537
if (key === 'cruisingFL') dbKey = 'cruisingfl';
421-
if (key === 'clearedFL') dbKey = 'clearedfl';
538+
if (key === 'clearedFL') dbKey = 'clearedfl';
422539
if (allowedColumns.includes(dbKey)) {
423540
dbUpdates[dbKey] = dbKey === 'clearance' ? String(value) : value;
424541
}

server/routes/flights.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import requireAuth from '../middleware/auth.js';
33
import { requireFlightAccess } from '../middleware/flightAccess.js';
44
import {
55
getFlightsBySession,
6+
getFlightsByUser,
7+
getFlightByIdForUser,
8+
getFlightLogsForUser,
9+
claimFlightForUser,
10+
getFlightById,
611
addFlight,
712
updateFlight,
813
deleteFlight,
@@ -54,6 +59,116 @@ process.on('SIGTERM', () => {
5459
activeAcarsTerminals.clear();
5560
});
5661

62+
// GET: /api/flights/me - get flights submitted by current user
63+
router.get('/me/list', requireAuth, async (req, res) => {
64+
try {
65+
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
66+
const flights = await getFlightsByUser(req.user.userId);
67+
res.json(flights);
68+
} catch {
69+
res.status(500).json({ error: 'Failed to fetch your flights' });
70+
}
71+
});
72+
73+
// GET: /api/flights/me/:flightId - get one owned flight
74+
router.get('/me/:flightId', requireAuth, async (req, res) => {
75+
try {
76+
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
77+
const flight = await getFlightByIdForUser(req.user.userId, req.params.flightId);
78+
if (!flight) return res.status(404).json({ error: 'Flight not found' });
79+
res.json(flight);
80+
} catch {
81+
res.status(500).json({ error: 'Failed to fetch flight' });
82+
}
83+
});
84+
85+
// GET: /api/flights/me/:flightId/logs - get owned flight logs
86+
router.get('/me/:flightId/logs', requireAuth, async (req, res) => {
87+
try {
88+
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
89+
const logsData = await getFlightLogsForUser(
90+
req.user.userId,
91+
req.params.flightId
92+
);
93+
res.json(logsData);
94+
} catch {
95+
res.status(500).json({ error: 'Failed to fetch flight logs' });
96+
}
97+
});
98+
99+
// POST: /api/flights/claim - claim a just-submitted guest flight after login
100+
router.post('/claim', requireAuth, async (req, res) => {
101+
try {
102+
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
103+
104+
const { sessionId, flightId, acarsToken } = req.body ?? {};
105+
if (!sessionId || !flightId || !acarsToken) {
106+
return res.status(400).json({ error: 'Missing required fields' });
107+
}
108+
109+
const result = await claimFlightForUser(
110+
String(sessionId),
111+
String(flightId),
112+
String(acarsToken),
113+
req.user.userId
114+
);
115+
116+
if (!result.ok) {
117+
if (result.reason === 'not_found') {
118+
return res.status(404).json({ error: 'Flight not found' });
119+
}
120+
if (result.reason === 'invalid_token') {
121+
return res.status(403).json({ error: 'Invalid claim token' });
122+
}
123+
if (result.reason === 'already_claimed') {
124+
return res.status(409).json({ error: 'Flight already claimed' });
125+
}
126+
return res.status(400).json({ error: 'Unable to claim flight' });
127+
}
128+
129+
res.json({ success: true });
130+
} catch {
131+
res.status(500).json({ error: 'Failed to claim flight' });
132+
}
133+
});
134+
135+
// GET: /api/flights/:sessionId/:flightId/acars-flight - get specific flight for ACARS token
136+
router.get(
137+
'/:sessionId/:flightId/acars-flight',
138+
acarsValidationLimiter,
139+
async (req, res) => {
140+
try {
141+
const { sessionId, flightId } = req.params;
142+
const acarsToken =
143+
typeof req.query.acars_token === 'string'
144+
? req.query.acars_token
145+
: undefined;
146+
147+
if (!acarsToken) {
148+
return res.status(400).json({ error: 'Missing access token' });
149+
}
150+
151+
const validation = await validateAcarsAccess(sessionId, flightId, acarsToken);
152+
if (!validation.valid) {
153+
return res.status(403).json({ error: 'Invalid ACARS token' });
154+
}
155+
156+
const flight = await getFlightById(sessionId, flightId);
157+
if (!flight) {
158+
return res.status(404).json({ error: 'Flight not found' });
159+
}
160+
161+
const { user_id, ip_address, acars_token, ...sanitizedFlight } = flight;
162+
void user_id;
163+
void ip_address;
164+
void acars_token;
165+
res.json(sanitizedFlight);
166+
} catch {
167+
res.status(500).json({ error: 'Failed to load flight' });
168+
}
169+
}
170+
);
171+
57172
// GET: /api/flights/:sessionId - get all flights for a session
58173
router.get('/:sessionId', requireAuth, async (req, res) => {
59174
try {

src/App.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import Create from './pages/Create';
77
import Sessions from './pages/Sessions';
88
import Submit from './pages/Submit';
99
import Flights from './pages/Flights';
10+
import MyFlights from './pages/MyFlights';
11+
import MyFlightDetail from './pages/MyFlightDetail';
1012
import Settings from './pages/Settings';
1113
import PFATCFlights from './pages/PFATCFlights';
1214
import ACARS from './pages/ACARS';
@@ -191,6 +193,22 @@ export default function App() {
191193
</ProtectedRoute>
192194
}
193195
/>
196+
<Route
197+
path="/my-flights"
198+
element={
199+
<ProtectedRoute>
200+
<MyFlights />
201+
</ProtectedRoute>
202+
}
203+
/>
204+
<Route
205+
path="/my-flights/:id"
206+
element={
207+
<ProtectedRoute>
208+
<MyFlightDetail />
209+
</ProtectedRoute>
210+
}
211+
/>
194212

195213
<Route
196214
path="/admin/*"

src/components/Navbar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,14 @@ export default function Navbar({
456456
>
457457
My Sessions
458458
</a>
459+
{user && (
460+
<a
461+
href="/my-flights"
462+
className="text-white hover:text-blue-400 transition-colors duration-300 font-medium"
463+
>
464+
My Flights
465+
</a>
466+
)}
459467
<a
460468
href="/pfatc"
461469
className="text-white hover:text-blue-400 transition-colors duration-300 font-medium"

0 commit comments

Comments
 (0)