From 2726d805512a413f0a7cb9a2b8dbfd0c8c0861e4 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Sun, 29 Mar 2026 15:10:13 +0100 Subject: [PATCH] Add Docker configuration and initial server setup for bus_running_record app --- Dockerfile | 22 ++ docker-compose.yml | 13 ++ nginx.conf | 46 ++++ .../functions/operations-duty-detail/index.ts | 82 +++++++ .../operations-schedule-meta/index.ts | 116 ++++++++++ .../operations-schedule-upload/index.ts | 207 ++++++++++++++++++ .../functions/operations-stop-detail/index.ts | 97 ++++++++ .../functions/operations-trip-detail/index.ts | 59 +++++ 8 files changed, 642 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 supabase/functions/operations-duty-detail/index.ts create mode 100644 supabase/functions/operations-schedule-meta/index.ts create mode 100644 supabase/functions/operations-schedule-upload/index.ts create mode 100644 supabase/functions/operations-stop-detail/index.ts create mode 100644 supabase/functions/operations-trip-detail/index.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac895b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Build stage +FROM cirrusci/flutter:latest AS builder + +WORKDIR /app +COPY pubspec.* ./ +COPY lib ./lib +COPY web ./web +COPY assets ./assets + +RUN flutter pub get +RUN flutter build web --release + +# Serve stage +FROM nginx:alpine + +COPY --from=builder /app/build/web /usr/share/nginx/html + +COPY nginx.conf /etc/nginx/nginx.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c17c803 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' + +services: + web: + build: + context: . + dockerfile: Dockerfile + ports: + - "80:80" + environment: + - TZ=UTC + restart: unless-stopped + container_name: bus_running_record_web \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..9a66e50 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,46 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Cache busting for versioned assets + location ~* ^/assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # All other requests go to index.html for routing + location / { + try_files $uri /index.html; + } + + error_page 404 /index.html; + } +} \ No newline at end of file diff --git a/supabase/functions/operations-duty-detail/index.ts b/supabase/functions/operations-duty-detail/index.ts new file mode 100644 index 0000000..df49442 --- /dev/null +++ b/supabase/functions/operations-duty-detail/index.ts @@ -0,0 +1,82 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { channel_id?: string; schedule_id?: string; duty_number?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = (body.channel_id ?? "").trim(); + const scheduleId = (body.schedule_id ?? "").trim(); + const dutyNumber = (body.duty_number ?? "").trim(); + + if (!channelId) return fail("channel_id is required"); + if (!scheduleId) return fail("schedule_id is required"); + if (!dutyNumber) return fail("duty_number is required"); + + // verify access + const { data: schedule, error: scheduleError } = await client + .from("operations_schedules") + .select("id, channel_id") + .eq("id", scheduleId) + .eq("channel_id", channelId) + .maybeSingle(); + + if (scheduleError) return fail(scheduleError.message, 400); + if (!schedule) return fail("forbidden", 403); + + const { data: tripRows, error: tripError } = await client + .from("operations_trips") + .select("id, trip_number, duty_number, bus_work_number, direction, sort_order") + .eq("schedule_id", scheduleId) + .eq("duty_number", dutyNumber) + .order("sort_order", { ascending: true }); + + if (tripError) return fail(tripError.message, 500); + + const trips = (tripRows ?? []) as { + id: string; + trip_number: string; + duty_number: string; + bus_work_number: string; + direction: string; + sort_order: number; + }[]; + + if (trips.length === 0) return json({ trips: [] }); + + const tripIds = trips.map((t) => t.id); + + const { data: stopRows, error: stopError } = await client + .from("operations_trip_stops") + .select("id, trip_id, stop_sequence, stop_name, scheduled_time") + .in("trip_id", tripIds) + .order("stop_sequence", { ascending: true }); + + if (stopError) return fail(stopError.message, 500); + + const stopsByTripId: Record = {}; + for (const stop of stopRows ?? []) { + const s = stop as { trip_id: string }; + if (!stopsByTripId[s.trip_id]) stopsByTripId[s.trip_id] = []; + stopsByTripId[s.trip_id]!.push(stop); + } + + const result = trips.map((trip) => ({ + ...trip, + stops: stopsByTripId[trip.id] ?? [], + })); + + return json({ trips: result }); +}); diff --git a/supabase/functions/operations-schedule-meta/index.ts b/supabase/functions/operations-schedule-meta/index.ts new file mode 100644 index 0000000..aa1f3c6 --- /dev/null +++ b/supabase/functions/operations-schedule-meta/index.ts @@ -0,0 +1,116 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { channel_id?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = (body.channel_id ?? "").trim(); + if (!channelId) return fail("channel_id is required"); + + const { data: channel, error: channelError } = await client + .from("channels") + .select("id, type") + .eq("id", channelId) + .eq("type", "operations") + .maybeSingle(); + + if (channelError) return fail(channelError.message, 400); + if (!channel) return fail("forbidden", 403); + + const { data: scheduleRow } = await client + .from("operations_schedules") + .select("id, version, source_file_name, parsed_at") + .eq("channel_id", channelId) + .eq("is_active", true) + .order("version", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (!scheduleRow) { + return json({ has_schedule: false }); + } + + const scheduleId = scheduleRow.id as string; + + const { data: tripRows, error: tripError } = await client + .from("operations_trips") + .select("id, trip_number, duty_number, bus_work_number, direction, sort_order") + .eq("schedule_id", scheduleId) + .order("sort_order", { ascending: true }); + + if (tripError) return fail(tripError.message, 500); + + const trips = (tripRows ?? []) as { + id: string; + trip_number: string; + duty_number: string; + bus_work_number: string; + direction: string; + sort_order: number; + }[]; + + const duties = [...new Set(trips.map((t) => t.duty_number))].sort(); + + const tripMeta = trips.map((t) => ({ + trip_number: t.trip_number, + duty_number: t.duty_number, + })); + + // get unique stop names + let stopNames: string[] = []; + if (trips.length > 0) { + const tripIds = trips.map((t) => t.id); + + const { data: stopRows, error: stopError } = await client + .from("operations_trip_stops") + .select("stop_name") + .in("trip_id", tripIds); + + if (stopError) return fail(stopError.message, 500); + + stopNames = [ + ...new Set( + (stopRows ?? []).map((r: { stop_name: string }) => + (r.stop_name ?? "").trim() + ).filter((s: string) => s.length > 0) + ), + ].sort(); + } + + const { data: aliasRows } = await client + .from("operations_stop_aliases") + .select("raw_stop_name, alias_stop_name, source") + .eq("channel_id", channelId); + + const aliases = (aliasRows ?? []).map((r: { + raw_stop_name: string; + alias_stop_name: string; + source: string; + }) => ({ + raw_stop_name: r.raw_stop_name, + alias_stop_name: r.alias_stop_name, + source: r.source ?? "user", + })); + + return json({ + has_schedule: true, + schedule_id: scheduleId, + duties, + trips: tripMeta, + stop_names: stopNames, + aliases, + }); +}); diff --git a/supabase/functions/operations-schedule-upload/index.ts b/supabase/functions/operations-schedule-upload/index.ts new file mode 100644 index 0000000..bceb146 --- /dev/null +++ b/supabase/functions/operations-schedule-upload/index.ts @@ -0,0 +1,207 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +type StopRow = { + stop_sequence: number; + stop_name: string; + scheduled_time?: string | null; +}; + +type TripRow = { + trip_number: string; + duty_number: string; + bus_work_number: string; + direction: string; + service_code: string; + sort_order: number; + stops: StopRow[]; +}; + +type RequestBody = { + channel_id?: string; + file_name?: string; + source_mime?: string; + parser?: string; + trips?: TripRow[]; +}; + +const BATCH_SIZE = 500; + +async function decompressGzip(compressed: Uint8Array): Promise { + const ds = new DecompressionStream("gzip"); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + writer.write(compressed); + writer.close(); + + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + const totalLen = chunks.reduce((acc, c) => acc + c.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: RequestBody; + + const contentEncoding = req.headers.get("content-encoding") ?? ""; + const isGzip = contentEncoding.toLowerCase() === "gzip"; + + try { + if (isGzip) { + const raw = new Uint8Array(await req.arrayBuffer()); + const decompressed = await decompressGzip(raw); + const text = new TextDecoder().decode(decompressed); + body = JSON.parse(text); + } else { + body = await req.json(); + } + } catch { + return fail("Failed to parse request body"); + } + + const channelId = (body.channel_id ?? "").trim(); + const fileName = (body.file_name ?? "").trim(); + const sourceMime = (body.source_mime ?? "").trim(); + const parser = (body.parser ?? "").trim(); + const trips = Array.isArray(body.trips) ? body.trips : []; + + if (!channelId) return fail("channel_id is required"); + if (!fileName) return fail("file_name is required"); + if (!sourceMime) return fail("source_mime is required"); + if (!parser) return fail("parser is required"); + if (trips.length === 0) return fail("trips is empty"); + + // Verify channel access + const { data: channel, error: channelError } = await client + .from("channels") + .select("id, type") + .eq("id", channelId) + .eq("type", "operations") + .maybeSingle(); + + if (channelError) return fail(channelError.message, 400); + if (!channel) return fail("forbidden", 403); + + // Get next version + const { data: existing } = await client + .from("operations_schedules") + .select("version") + .eq("channel_id", channelId) + .order("version", { ascending: false }) + .limit(1); + + const latestVersion = + Array.isArray(existing) && existing.length > 0 + ? ((existing[0].version as number) ?? 0) + : 0; + const nextVersion = latestVersion + 1; + + // Deactivate old schedules + const { error: deactivateError } = await client + .from("operations_schedules") + .update({ is_active: false }) + .eq("channel_id", channelId) + .eq("is_active", true); + + if (deactivateError) return fail(deactivateError.message, 500); + + // Insert schedule row + const { data: scheduleRow, error: scheduleError } = await client + .from("operations_schedules") + .insert({ + channel_id: channelId, + version: nextVersion, + source_file_name: fileName, + source_mime: sourceMime, + parser, + parse_status: "parsed", + uploaded_by: user.id, + is_active: true, + parsed_at: new Date().toISOString(), + }) + .select("id") + .single(); + + if (scheduleError || !scheduleRow) { + return fail(scheduleError?.message ?? "Failed to create schedule", 500); + } + + const scheduleId = scheduleRow.id as string; + + // Batch insert trips + const tripInserts = trips.map((t, i) => ({ + schedule_id: scheduleId, + trip_number: t.trip_number, + duty_number: t.duty_number, + bus_work_number: t.bus_work_number, + direction: t.direction, + service_code: t.service_code, + sort_order: t.sort_order ?? i, + })); + + const { data: insertedTrips, error: tripError } = await client + .from("operations_trips") + .insert(tripInserts) + .select("id, trip_number, duty_number"); + + if (tripError || !insertedTrips) { + return fail(tripError?.message ?? "Failed to insert trips", 500); + } + + // Map trip_number+duty_number -> id + // (trip_number alone might not be unique across duties) + const tripIdMap = new Map(); + for (const row of insertedTrips) { + const key = `${row.trip_number}__${row.duty_number}`; + tripIdMap.set(key, row.id as string); + } + + // Build all stop rows + const allStopRows: Record[] = []; + for (const trip of trips) { + const key = `${trip.trip_number}__${trip.duty_number}`; + const tripId = tripIdMap.get(key); + if (!tripId) continue; + + for (const stop of trip.stops ?? []) { + allStopRows.push({ + trip_id: tripId, + stop_sequence: stop.stop_sequence, + stop_name: stop.stop_name, + scheduled_time: stop.scheduled_time ?? null, + }); + } + } + + // Batch insert stops in chunks + for (let i = 0; i < allStopRows.length; i += BATCH_SIZE) { + const chunk = allStopRows.slice(i, i + BATCH_SIZE); + const { error: stopError } = await client + .from("operations_trip_stops") + .insert(chunk); + + if (stopError) return fail(stopError.message, 500); + } + + return json({ schedule_id: scheduleId, version: nextVersion }); +}); diff --git a/supabase/functions/operations-stop-detail/index.ts b/supabase/functions/operations-stop-detail/index.ts new file mode 100644 index 0000000..661c453 --- /dev/null +++ b/supabase/functions/operations-stop-detail/index.ts @@ -0,0 +1,97 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +const PAGE_SIZE = 20; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { + channel_id?: string; + schedule_id?: string; + stop_name?: string; + offset?: number; + limit?: number; + }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = (body.channel_id ?? "").trim(); + const scheduleId = (body.schedule_id ?? "").trim(); + const stopName = (body.stop_name ?? "").trim(); + const offset = Math.max(0, typeof body.offset === "number" ? body.offset : 0); + const limit = Math.min(100, Math.max(1, typeof body.limit === "number" ? body.limit : PAGE_SIZE)); + + if (!channelId) return fail("channel_id is required"); + if (!scheduleId) return fail("schedule_id is required"); + if (!stopName) return fail("stop_name is required"); + + const { data: schedule, error: scheduleError } = await client + .from("operations_schedules") + .select("id, channel_id") + .eq("id", scheduleId) + .eq("channel_id", channelId) + .maybeSingle(); + + if (scheduleError) return fail(scheduleError.message, 400); + if (!schedule) return fail("forbidden", 403); + + const { data: tripRows, error: tripError } = await client + .from("operations_trips") + .select("id, trip_number, duty_number, bus_work_number, direction") + .eq("schedule_id", scheduleId); + + if (tripError) return fail(tripError.message, 500); + + const trips = (tripRows ?? []) as { + id: string; + trip_number: string; + duty_number: string; + bus_work_number: string; + direction: string; + }[]; + + if (trips.length === 0) return json({ schedule: [], has_more: false }); + + const tripIds = trips.map((t) => t.id); + const tripById = Object.fromEntries(trips.map((t) => [t.id, t])); + + const { data: stopRows, error: stopError } = await client + .from("operations_trip_stops") + .select("trip_id, scheduled_time") + .in("trip_id", tripIds) + .eq("stop_name", stopName); + + if (stopError) return fail(stopError.message, 500); + + const rows = (stopRows ?? []) as { trip_id: string; scheduled_time: string | null }[]; + + const result = rows + .map((row) => { + const trip = tripById[row.trip_id]; + if (!trip) return null; + return { trip, scheduled_time: row.scheduled_time ?? null }; + }) + .filter(Boolean) as { trip: typeof trips[0]; scheduled_time: string | null }[]; + + result.sort((a, b) => { + const aNum = parseInt(a.trip.trip_number, 10); + const bNum = parseInt(b.trip.trip_number, 10); + if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum; + return a.trip.trip_number.localeCompare(b.trip.trip_number); + }); + + const page = result.slice(offset, offset + limit); + const hasMore = offset + limit < result.length; + + return json({ schedule: page, has_more: hasMore }); +}); \ No newline at end of file diff --git a/supabase/functions/operations-trip-detail/index.ts b/supabase/functions/operations-trip-detail/index.ts new file mode 100644 index 0000000..4d8695e --- /dev/null +++ b/supabase/functions/operations-trip-detail/index.ts @@ -0,0 +1,59 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { channel_id?: string; schedule_id?: string; trip_number?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = (body.channel_id ?? "").trim(); + const scheduleId = (body.schedule_id ?? "").trim(); + const tripNumber = (body.trip_number ?? "").trim(); + + if (!channelId) return fail("channel_id is required"); + if (!scheduleId) return fail("schedule_id is required"); + if (!tripNumber) return fail("trip_number is required"); + + const { data: schedule, error: scheduleError } = await client + .from("operations_schedules") + .select("id, channel_id") + .eq("id", scheduleId) + .eq("channel_id", channelId) + .maybeSingle(); + + if (scheduleError) return fail(scheduleError.message, 400); + if (!schedule) return fail("forbidden", 403); + + const { data: tripRow, error: tripError } = await client + .from("operations_trips") + .select("id, trip_number, duty_number, bus_work_number, direction, sort_order") + .eq("schedule_id", scheduleId) + .eq("trip_number", tripNumber) + .maybeSingle(); + + if (tripError) return fail(tripError.message, 500); + if (!tripRow) return json({ trip: null, stops: [] }); + + const trip = tripRow as { id: string }; + + const { data: stopRows, error: stopError } = await client + .from("operations_trip_stops") + .select("id, trip_id, stop_sequence, stop_name, scheduled_time") + .eq("trip_id", trip.id) + .order("stop_sequence", { ascending: true }); + + if (stopError) return fail(stopError.message, 500); + + return json({ trip: tripRow, stops: stopRows ?? [] }); +});