607 lines
21 KiB
TypeScript
607 lines
21 KiB
TypeScript
// In-memory backend used when VITE_USE_MOCK_API=true, so the frontend runs and
|
||
// e2e-tests with no Django. The session is carried by a cookie (set via the
|
||
// `session` field below), so each browser context is independent — no global
|
||
// session state to leak between tests.
|
||
type Json = Record<string, unknown>;
|
||
interface MockUser {
|
||
username: string;
|
||
email: string;
|
||
password: string;
|
||
is_staff: boolean;
|
||
is_active?: boolean;
|
||
}
|
||
|
||
const users = new Map<string, MockUser>([
|
||
['demo', { username: 'demo', email: 'demo@example.com', password: 'demo', is_staff: false }],
|
||
['admin', { username: 'admin', email: 'admin@example.com', password: 'admin', is_staff: true }]
|
||
]);
|
||
|
||
// Library items (saved points / templates), seeded so the pickers have content.
|
||
let nextId = 100;
|
||
const points: Array<Json & { id: number }> = [
|
||
{ id: 1, name: 'sjsa-start', lat: 62.1234, lon: 129.1234, alt: 100 },
|
||
{ id: 2, name: 'Тикси', lat: 71.6372, lon: 128.8686, alt: 10 }
|
||
];
|
||
const templates: Array<Json & { id: number }> = [
|
||
{
|
||
id: 1,
|
||
name: 'Высотный',
|
||
description: '',
|
||
prediction_mode: 'single',
|
||
model: '',
|
||
dataset: '',
|
||
flight_parameters: {
|
||
ascent_rate: 5,
|
||
burst_altitude: 33000,
|
||
dataset: '',
|
||
descent_rate: 6,
|
||
format: 'json',
|
||
launch_altitude: 100,
|
||
launch_latitude: 62.1234,
|
||
launch_longitude: 129.1234,
|
||
profile: 'standard_profile',
|
||
version: 2
|
||
}
|
||
}
|
||
];
|
||
|
||
export interface HandleResult {
|
||
status: number;
|
||
body: Json;
|
||
/** undefined = leave the session cookie unchanged; string = set; null = clear. */
|
||
session?: string | null;
|
||
}
|
||
|
||
const ok = (body: Json, status = 200): HandleResult => ({ status, body });
|
||
const err = (status: number, detail: string): HandleResult => ({ status, body: { detail } });
|
||
|
||
/** Build a plausible ascent+descent trajectory from the request params. */
|
||
function synthPrediction(body: Json): Json {
|
||
const lat0 = Number(body.launch_latitude ?? 62.1234);
|
||
const lon0 = Number(body.launch_longitude ?? 129.1234);
|
||
const alt0 = Number(body.launch_altitude ?? 0);
|
||
const ascentRate = Math.max(Number(body.ascent_rate ?? 5), 0.1);
|
||
const burstAlt = Number(body.burst_altitude ?? 30000);
|
||
const descentRate = Math.max(Number(body.descent_rate ?? 5), 0.1);
|
||
const startIso = String(body.launch_datetime ?? new Date().toISOString());
|
||
const start = new Date(startIso).getTime();
|
||
|
||
const N = 24;
|
||
const windLat = 0.004;
|
||
const windLon = 0.01;
|
||
const ascentDur = ((burstAlt - alt0) / ascentRate) * 1000; // ms
|
||
const descentDur = (burstAlt / descentRate) * 1000; // ms
|
||
|
||
const ascent = [];
|
||
for (let i = 0; i <= N; i++) {
|
||
const f = i / N;
|
||
ascent.push({
|
||
altitude: alt0 + (burstAlt - alt0) * f,
|
||
datetime: new Date(start + ascentDur * f).toISOString(),
|
||
latitude: lat0 + windLat * i,
|
||
longitude: lon0 + windLon * i
|
||
});
|
||
}
|
||
const burstLat = lat0 + windLat * N;
|
||
const burstLon = lon0 + windLon * N;
|
||
const descent = [];
|
||
for (let i = 0; i <= N; i++) {
|
||
const f = i / N;
|
||
descent.push({
|
||
altitude: burstAlt * (1 - f),
|
||
datetime: new Date(start + ascentDur + descentDur * f).toISOString(),
|
||
latitude: burstLat + windLat * i * 0.6,
|
||
longitude: burstLon + windLon * i * 0.6
|
||
});
|
||
}
|
||
return {
|
||
metadata: {
|
||
start_datetime: startIso,
|
||
complete_datetime: new Date(start + ascentDur + descentDur).toISOString()
|
||
},
|
||
prediction: [
|
||
{ stage: 'ascent', trajectory: ascent },
|
||
{ stage: 'descent', trajectory: descent }
|
||
]
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Synthetic [U, V] wind grid in the wind-js-server format, matching the real
|
||
* predictor's conventions: longitudes in 0..360 and a north→south scan
|
||
* (la1 > la2) with positive dy.
|
||
*/
|
||
function synthWindField(q: URLSearchParams): Json[] {
|
||
const minLat = Number(q.get('min_lat') ?? 55);
|
||
const maxLat = Number(q.get('max_lat') ?? 70);
|
||
const minLng = Number(q.get('min_lng') ?? 120);
|
||
const maxLng = Number(q.get('max_lng') ?? 140);
|
||
const step = Math.max(0.25, Number(q.get('step') ?? 1));
|
||
|
||
const nx = Math.max(2, Math.floor((maxLng - minLng) / step) + 1);
|
||
const ny = Math.max(2, Math.floor((maxLat - minLat) / step) + 1);
|
||
const to360 = (lng: number) => ((lng % 360) + 360) % 360;
|
||
|
||
const u: number[] = [];
|
||
const v: number[] = [];
|
||
for (let j = 0; j < ny; j++) {
|
||
const lat = maxLat - j * step; // north → south
|
||
for (let i = 0; i < nx; i++) {
|
||
const lng = minLng + i * step;
|
||
// Smooth jet-like flow with a wide speed spread (~2–30 m/s) so the
|
||
// colour ramp and trail density are representative in mock mode.
|
||
u.push(14 + 13 * Math.sin((lat * Math.PI) / 9) * Math.cos((lng * Math.PI) / 14));
|
||
v.push(7 * Math.cos((lng * Math.PI) / 11) + 5 * Math.sin((lat * Math.PI) / 7));
|
||
}
|
||
}
|
||
|
||
const header = (name: string, unit: string) => ({
|
||
parameterUnit: unit,
|
||
parameterNumberName: name,
|
||
nx,
|
||
ny,
|
||
lo1: to360(minLng),
|
||
la1: maxLat,
|
||
lo2: to360(minLng + (nx - 1) * step),
|
||
la2: maxLat - (ny - 1) * step,
|
||
dx: step,
|
||
dy: step,
|
||
refTime: '2026-01-01T00:00:00Z'
|
||
});
|
||
|
||
return [
|
||
{ header: header('eastward_wind', 'm.s-1'), data: u },
|
||
{ header: header('northward_wind', 'm.s-1'), data: v }
|
||
];
|
||
}
|
||
|
||
/** Deterministic ascent track for a satellite id, newest-first like the backend. */
|
||
export function synthTelemetryHistory(id: string, count = 20): Json[] {
|
||
// Seed from the id so a given satellite always yields the same track.
|
||
let seed = 0;
|
||
for (const ch of id) seed = (seed * 31 + ch.charCodeAt(0)) % 997;
|
||
const lat0 = 62 + (seed % 100) / 1000;
|
||
const lon0 = 129 + (seed % 137) / 1000;
|
||
const startSec = Math.floor(Date.parse('2026-01-01T00:00:00Z') / 1000);
|
||
|
||
const packets: Json[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
packets.push({
|
||
id: `${id}-${i}`,
|
||
timestamp: startSec + i * 60,
|
||
lat: lat0 + i * 0.004,
|
||
lon: lon0 + i * 0.01,
|
||
alt: i * 900,
|
||
payload: { temp: -5 - i, volt: 4.2 - i * 0.01 },
|
||
raw_data: {}
|
||
});
|
||
}
|
||
return packets.reverse(); // newest-first
|
||
}
|
||
|
||
interface MockFlight {
|
||
id: string;
|
||
name: string;
|
||
privacy: 'public' | 'unlisted' | 'private';
|
||
owner: string;
|
||
}
|
||
const flights: MockFlight[] = [
|
||
{ id: '550e8400-e29b-41d4-a716-446655440000', name: 'ЯКС-1', privacy: 'public', owner: 'demo' },
|
||
{ id: '550e8400-e29b-41d4-a716-446655440001', name: 'Тестовый', privacy: 'unlisted', owner: 'demo' },
|
||
{ id: '550e8400-e29b-41d4-a716-446655440002', name: 'Секретный', privacy: 'private', owner: 'admin' }
|
||
];
|
||
|
||
function flightOut(f: MockFlight, me: MockUser | null): Json {
|
||
const history = synthTelemetryHistory(f.id);
|
||
const last = history[0] as { timestamp: number; lat: number; lon: number; alt: number };
|
||
return {
|
||
id: f.id,
|
||
name: f.name,
|
||
privacy: f.privacy,
|
||
created_at: '2026-01-01T00:00:00Z',
|
||
is_owner: !!me && me.username === f.owner,
|
||
last_position: { timestamp: last.timestamp, lat: last.lat, lon: last.lon, alt: last.alt },
|
||
packet_count: history.length
|
||
};
|
||
}
|
||
|
||
const flightVisible = (f: MockFlight, me: MockUser | null) =>
|
||
f.privacy !== 'private' || (!!me && me.username === f.owner);
|
||
|
||
interface StoredRun {
|
||
id: string;
|
||
created_at: string;
|
||
updated_at: string;
|
||
result: Json;
|
||
request: Json;
|
||
/** Set only once the owner shares the run; access is by token, never by id. */
|
||
share_token?: string | null;
|
||
}
|
||
const runs: StoredRun[] = [];
|
||
let runSeq = 0;
|
||
|
||
export function handle(
|
||
method: string,
|
||
path: string,
|
||
body: Json,
|
||
sessionUser: string | null,
|
||
query: URLSearchParams = new URLSearchParams()
|
||
): HandleResult {
|
||
const me = sessionUser ? (users.get(sessionUser) ?? null) : null;
|
||
|
||
if (path === '/api/csrf/') return ok({ detail: 'CSRF cookie set' });
|
||
if (path === '/api/session/') return ok({ isAuthenticated: me !== null });
|
||
|
||
if (path === '/api/whoami/') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
return ok({ username: me.username, is_staff: me.is_staff });
|
||
}
|
||
|
||
if (path === '/api/login/' && method === 'POST') {
|
||
const u = users.get(String(body.username ?? ''));
|
||
if (u && u.password === String(body.password ?? ''))
|
||
return { status: 200, body: { detail: 'Successfully logged in.' }, session: u.username };
|
||
return err(400, 'Invalid credentials.');
|
||
}
|
||
|
||
if (path === '/api/logout/' && method === 'POST')
|
||
return { status: 200, body: { detail: 'Successfully logged out.' }, session: null };
|
||
|
||
if (path === '/api/register/' && method === 'POST') {
|
||
const username = String(body.username ?? '').trim();
|
||
const email = String(body.email ?? '').trim();
|
||
const password = String(body.password ?? '');
|
||
if (username.length < 3) return err(400, 'Username must be at least 3 characters');
|
||
if (users.has(username)) return err(400, 'Username already taken.');
|
||
if ([...users.values()].some((u) => u.email.toLowerCase() === email.toLowerCase()))
|
||
return err(400, 'Email already registered.');
|
||
users.set(username, { username, email, password, is_staff: false });
|
||
return { status: 201, body: { detail: 'Account created.' }, session: username };
|
||
}
|
||
|
||
if (path === '/api/profile/') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
if (method === 'GET')
|
||
return ok({ username: me.username, email: me.email, first_name: '', last_name: '' });
|
||
if (method === 'PATCH') {
|
||
if (typeof body.email === 'string') me.email = body.email;
|
||
return ok({ username: me.username, email: me.email, first_name: '', last_name: '' });
|
||
}
|
||
}
|
||
|
||
if (path === '/api/profile/change-password/' && method === 'POST') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
if (me.password !== String(body.old_password ?? '')) return err(400, 'Old password is incorrect');
|
||
me.password = String(body.new_password ?? '');
|
||
return ok({ detail: 'Password changed successfully' });
|
||
}
|
||
|
||
if (path === '/api/profile/delete-data/' && method === 'DELETE') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
return ok({ detail: 'All user data deleted successfully' });
|
||
}
|
||
|
||
if (path === '/api/profile/delete-account/' && method === 'DELETE') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
if (me.password !== String(body.password ?? '')) return err(400, 'Incorrect password');
|
||
users.delete(me.username);
|
||
return { status: 200, body: { detail: 'Account deleted successfully' }, session: null };
|
||
}
|
||
|
||
if (path === '/api/flights/public/' && method === 'GET') {
|
||
return ok({
|
||
flights: flights.filter((f) => f.privacy === 'public').map((f) => flightOut(f, me))
|
||
} as unknown as Json);
|
||
}
|
||
|
||
const flightMatch = path.match(/^\/api\/flights\/([0-9a-f-]{36})\/$/i);
|
||
if (flightMatch) {
|
||
const f = flights.find((x) => x.id === flightMatch[1]);
|
||
if (method === 'GET') {
|
||
// A private flight is indistinguishable from a missing one.
|
||
if (!f || !flightVisible(f, me)) return err(404, 'Not found');
|
||
return ok(flightOut(f, me));
|
||
}
|
||
if (method === 'PATCH') {
|
||
if (!me) return err(401, 'Authentication required');
|
||
if (!f) return err(404, 'Not found');
|
||
if (f.owner !== me.username) return err(403, 'Not your flight');
|
||
if (typeof body.privacy === 'string') {
|
||
if (!['public', 'unlisted', 'private'].includes(body.privacy))
|
||
return err(400, 'privacy must be one of: public, unlisted, private');
|
||
f.privacy = body.privacy as MockFlight['privacy'];
|
||
}
|
||
if (typeof body.name === 'string') f.name = body.name;
|
||
return ok(flightOut(f, me));
|
||
}
|
||
}
|
||
|
||
// Telemetry history: /api/<uuid>/telemetry/ (newest-first, like the backend).
|
||
const telemetryMatch = path.match(/^\/api\/([0-9a-f-]{36})\/telemetry\/$/i);
|
||
if (telemetryMatch && method === 'GET') {
|
||
// Public/unlisted tracks are readable anonymously; private ones are not.
|
||
const f = flights.find((x) => x.id === telemetryMatch[1]);
|
||
if (f && !flightVisible(f, me)) return err(404, 'Not found');
|
||
return ok(synthTelemetryHistory(telemetryMatch[1]) as unknown as Json);
|
||
}
|
||
|
||
if (path === '/api/wind/field/' && method === 'GET') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
return ok(synthWindField(query) as unknown as Json);
|
||
}
|
||
|
||
if (path === '/api/wind/meta/' && method === 'GET') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
return ok({
|
||
source: 'gfs-0p50-3h',
|
||
epoch: '2026-01-01T00:00:00Z',
|
||
altitudes: [0, 5000, 10000, 20000, 30000],
|
||
bbox: { min_lat: -90, max_lat: 90, min_lng: -180, max_lng: 180 }
|
||
});
|
||
}
|
||
|
||
if (path === '/api/admin/users/' && method === 'GET') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
if (!me.is_staff) return err(403, 'Staff only');
|
||
const q = (query.get('q') ?? '').toLowerCase();
|
||
const limit = Number(query.get('limit') ?? 20);
|
||
const skip = Number(query.get('skip') ?? 0);
|
||
const all = [...users.values()]
|
||
.map((u, i) => ({
|
||
id: i + 1,
|
||
username: u.username,
|
||
email: u.email,
|
||
is_active: u.is_active ?? true,
|
||
is_staff: u.is_staff,
|
||
date_joined: '2026-01-01T00:00:00Z',
|
||
last_login: null
|
||
}))
|
||
.filter(
|
||
(u) => !q || u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
||
);
|
||
return ok({ total: all.length, limit, skip, users: all.slice(skip, skip + limit) } as unknown as Json);
|
||
}
|
||
|
||
const adminUserMatch = path.match(/^\/api\/admin\/users\/(\d+)\/$/);
|
||
if (adminUserMatch && method === 'PATCH') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
if (!me.is_staff) return err(403, 'Staff only');
|
||
const idx = Number(adminUserMatch[1]) - 1;
|
||
const target = [...users.values()][idx];
|
||
if (!target) return err(404, 'Not found');
|
||
if (target.username === me.username && body.is_active === false)
|
||
return err(400, 'You cannot deactivate your own account.');
|
||
target.is_active = Boolean(body.is_active);
|
||
return ok({
|
||
id: idx + 1,
|
||
username: target.username,
|
||
email: target.email,
|
||
is_active: target.is_active,
|
||
is_staff: target.is_staff,
|
||
date_joined: '2026-01-01T00:00:00Z',
|
||
last_login: null
|
||
});
|
||
}
|
||
|
||
if (path === '/api/predictions/' && method === 'POST') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
const result = synthPrediction(body);
|
||
// Mirror the backend: every run is persisted and shows up in history.
|
||
runSeq += 1;
|
||
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
|
||
runs.unshift({
|
||
id: `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`,
|
||
created_at: stamp,
|
||
updated_at: stamp,
|
||
result,
|
||
request: body
|
||
});
|
||
return ok({ result });
|
||
}
|
||
|
||
if (path === '/api/predictions/v2/' && method === 'POST') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
// Flatten the v2 stage request into the params synthPrediction understands,
|
||
// so a custom profile still yields a plausible ascent+descent track.
|
||
const launch = (body.launch ?? {}) as Record<string, unknown>;
|
||
const profile = (body.profile ?? []) as Array<Record<string, any>>;
|
||
const ascent = profile.find((st) => st.model?.type === 'constant_rate');
|
||
const descent = profile.find((st) => st.model?.type === 'parachute_descent');
|
||
const burst = ascent?.constraints?.find((c: any) => c.type === 'altitude')?.limit;
|
||
const result = synthPrediction({
|
||
launch_latitude: launch.latitude,
|
||
launch_longitude: launch.longitude,
|
||
launch_altitude: launch.altitude,
|
||
launch_datetime: launch.time,
|
||
ascent_rate: ascent?.model?.rate ?? 5,
|
||
descent_rate: descent?.model?.sea_level_rate ?? 5,
|
||
burst_altitude: burst ?? 30000
|
||
});
|
||
|
||
runSeq += 1;
|
||
const id = `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`;
|
||
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
|
||
runs.unshift({ id, created_at: stamp, updated_at: stamp, request: body, result });
|
||
return ok({ id, created_at: stamp, result });
|
||
}
|
||
|
||
if (path === '/api/predictions/ensemble/' && method === 'POST') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
// Members scatter around the deterministic single-run landing.
|
||
const base = synthPrediction(body) as { prediction: Array<{ trajectory: Json[] }> };
|
||
const descent = base.prediction[1].trajectory;
|
||
const last = descent[descent.length - 1] as unknown as { latitude: number; longitude: number };
|
||
const members = Array.from({ length: 21 }, (_, i) => ({
|
||
member: i,
|
||
landing: {
|
||
lat: last.latitude + Math.sin(i * 1.7) * 0.05,
|
||
lng: last.longitude + Math.cos(i * 2.3) * 0.09,
|
||
alt: 0
|
||
}
|
||
}));
|
||
const n = members.length;
|
||
const meanLat = members.reduce((a, m) => a + m.landing.lat, 0) / n;
|
||
const meanLng = members.reduce((a, m) => a + m.landing.lng, 0) / n;
|
||
const kmLat = 111.32;
|
||
const kmLng = 111.32 * Math.cos((meanLat * Math.PI) / 180);
|
||
const radius =
|
||
members.reduce(
|
||
(a, m) =>
|
||
a +
|
||
Math.hypot((m.landing.lng - meanLng) * kmLng, (m.landing.lat - meanLat) * kmLat),
|
||
0
|
||
) / n;
|
||
|
||
runSeq += 1;
|
||
const id = `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`;
|
||
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
|
||
runs.unshift({
|
||
id,
|
||
created_at: stamp,
|
||
updated_at: stamp,
|
||
request: body,
|
||
result: {
|
||
ensemble: {
|
||
members,
|
||
failed: [],
|
||
footprint: {
|
||
count: n,
|
||
mean: { lat: meanLat, lng: meanLng },
|
||
radius_km: radius,
|
||
ellipse: { semi_major_km: radius * 1.6, semi_minor_km: radius * 1.1, bearing_deg: 35 }
|
||
}
|
||
}
|
||
} as unknown as Json
|
||
});
|
||
return ok({ id, status: 'pending', created_at: stamp }, 202);
|
||
}
|
||
|
||
const statusMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/status\/$/i);
|
||
if (statusMatch && method === 'GET') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
const run = runs.find((r) => r.id === statusMatch[1]);
|
||
if (!run) return err(404, 'Not found');
|
||
return ok({
|
||
id: run.id,
|
||
status: 'complete',
|
||
result: run.result,
|
||
error: null,
|
||
created_at: run.created_at,
|
||
updated_at: run.updated_at
|
||
});
|
||
}
|
||
|
||
if (path === '/api/predictions/list_user/' && method === 'GET') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
const limit = Number(query.get('limit') ?? 10);
|
||
const skip = Number(query.get('skip') ?? 0);
|
||
return ok({
|
||
total: runs.length,
|
||
limit,
|
||
skip,
|
||
predictions: runs.slice(skip, skip + limit)
|
||
} as unknown as Json);
|
||
}
|
||
|
||
const shareMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/share\/$/i);
|
||
if (shareMatch) {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
const run = runs.find((r) => r.id === shareMatch[1]);
|
||
if (!run) return err(404, 'Not found');
|
||
if (method === 'POST') {
|
||
// Re-sharing keeps the existing token so circulated links keep working.
|
||
run.share_token =
|
||
run.share_token ?? `11111111-2222-4333-8444-${String(runs.indexOf(run)).padStart(12, '0')}`;
|
||
return ok({ id: run.id, share_token: run.share_token }, 201);
|
||
}
|
||
if (method === 'DELETE') {
|
||
run.share_token = null;
|
||
return ok({ id: run.id, share_token: null });
|
||
}
|
||
}
|
||
|
||
const sharedMatch = path.match(/^\/api\/predictions\/shared\/([0-9a-f-]{36})\/$/i);
|
||
if (sharedMatch && method === 'GET') {
|
||
// Resolved by token only — anonymous, and an id grants nothing.
|
||
const run = runs.find((r) => r.share_token && r.share_token === sharedMatch[1]);
|
||
if (!run) return err(404, 'Not found');
|
||
return ok({
|
||
id: run.id,
|
||
created_at: run.created_at,
|
||
status: 'complete',
|
||
result: run.result
|
||
});
|
||
}
|
||
|
||
const detailMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/detail\/$/i);
|
||
if (detailMatch && method === 'GET') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
const run = runs.find((r) => r.id === detailMatch[1]);
|
||
if (!run) return err(404, 'Not found');
|
||
return ok({
|
||
...run,
|
||
status: 'complete',
|
||
error: null,
|
||
start_point: null,
|
||
template: null,
|
||
rate_profile: null
|
||
} as unknown as Json);
|
||
}
|
||
|
||
const deleteMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/delete\/$/i);
|
||
if (deleteMatch && method === 'DELETE') {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
const idx = runs.findIndex((r) => r.id === deleteMatch[1]);
|
||
if (idx === -1) return err(404, 'Not found');
|
||
runs.splice(idx, 1);
|
||
return { status: 204, body: {} };
|
||
}
|
||
|
||
if (path.startsWith('/api/saved-points/')) {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
if (path === '/api/saved-points/') {
|
||
if (method === 'GET') return ok(points as unknown as Json);
|
||
if (method === 'POST') {
|
||
const created = { ...body, id: nextId++ };
|
||
points.push(created);
|
||
return ok(created as Json, 201);
|
||
}
|
||
}
|
||
const id = Number(path.split('/').filter(Boolean).pop());
|
||
const idx = points.findIndex((p) => p.id === id);
|
||
if (idx === -1) return err(404, 'Not found');
|
||
if (method === 'PUT') {
|
||
points[idx] = { ...points[idx], ...body, id };
|
||
return ok(points[idx] as Json);
|
||
}
|
||
if (method === 'DELETE') {
|
||
points.splice(idx, 1);
|
||
return ok({ detail: 'deleted' });
|
||
}
|
||
}
|
||
|
||
if (path.startsWith('/api/saved-templates/')) {
|
||
if (!me) return err(401, 'Unauthorized');
|
||
if (path === '/api/saved-templates/') {
|
||
if (method === 'GET') return ok(templates as unknown as Json);
|
||
if (method === 'POST') {
|
||
const created = { ...body, id: nextId++ };
|
||
templates.push(created);
|
||
return ok(created as Json, 201);
|
||
}
|
||
}
|
||
const id = Number(path.split('/').filter(Boolean).pop());
|
||
const idx = templates.findIndex((tpl) => tpl.id === id);
|
||
if (idx === -1) return err(404, 'Not found');
|
||
if (method === 'PUT') {
|
||
templates[idx] = { ...templates[idx], ...body, id };
|
||
return ok(templates[idx] as Json);
|
||
}
|
||
if (method === 'DELETE') {
|
||
templates.splice(idx, 1);
|
||
return ok({ detail: 'deleted' });
|
||
}
|
||
}
|
||
|
||
return err(404, 'Not found');
|
||
}
|