Add Docker configuration and initial server setup for bus_running_record app
This commit is contained in:
parent
7049e58049
commit
2726d80551
8 changed files with 642 additions and 0 deletions
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -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;"]
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
46
nginx.conf
Normal file
46
nginx.conf
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
supabase/functions/operations-duty-detail/index.ts
Normal file
82
supabase/functions/operations-duty-detail/index.ts
Normal file
|
|
@ -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<string, typeof stopRows> = {};
|
||||||
|
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 });
|
||||||
|
});
|
||||||
116
supabase/functions/operations-schedule-meta/index.ts
Normal file
116
supabase/functions/operations-schedule-meta/index.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
207
supabase/functions/operations-schedule-upload/index.ts
Normal file
207
supabase/functions/operations-schedule-upload/index.ts
Normal file
|
|
@ -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<Uint8Array> {
|
||||||
|
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<string, string>();
|
||||||
|
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<string, unknown>[] = [];
|
||||||
|
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 });
|
||||||
|
});
|
||||||
97
supabase/functions/operations-stop-detail/index.ts
Normal file
97
supabase/functions/operations-stop-detail/index.ts
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
59
supabase/functions/operations-trip-detail/index.ts
Normal file
59
supabase/functions/operations-trip-detail/index.ts
Normal file
|
|
@ -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 ?? [] });
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue