Merge pull request #523 from Tria-plc/freight_feature/usermanagement

resolve conflict
This commit is contained in:
marshal
2026-07-08 01:25:17 +03:00
committed by GitHub
4 changed files with 359 additions and 141 deletions

View File

@@ -12,23 +12,28 @@ export class AddEmailToOtpVerifications1900000000000
name = "AddEmailToOtpVerifications1900000000000"; name = "AddEmailToOtpVerifications1900000000000";
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
// The table lives in the `freight` schema (the OtpVerification entity pins
// schema: "freight"). An earlier version of this migration targeted
// `public.otp_verifications`, which does not exist there — leaving the real
// freight table without an `email` column and OTP send failing with
// `column OtpVerification.email does not exist`. Target `freight` explicitly.
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE public.otp_verifications ALTER TABLE freight.otp_verifications
ALTER COLUMN phone DROP NOT NULL ALTER COLUMN phone DROP NOT NULL
`); `);
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE public.otp_verifications ALTER TABLE freight.otp_verifications
ADD COLUMN IF NOT EXISTS email varchar UNIQUE ADD COLUMN IF NOT EXISTS email varchar UNIQUE
`); `);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE public.otp_verifications ALTER TABLE freight.otp_verifications
DROP COLUMN IF EXISTS email DROP COLUMN IF EXISTS email
`); `);
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE public.otp_verifications ALTER TABLE freight.otp_verifications
ALTER COLUMN phone SET NOT NULL ALTER COLUMN phone SET NOT NULL
`); `);
} }

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair: AddEmailToOtpVerifications1900000000000 originally altered
* `public.otp_verifications`, but the OtpVerification entity pins
* schema: "freight". On any DB where that migration already ran (and is recorded
* as executed, so it won't run again), the real `freight.otp_verifications` table
* never got the `email` column and `phone` was never made nullable — so OTP send
* dies with `column OtpVerification.email does not exist`.
*
* This migration re-applies the change against the correct schema. Idempotent
* (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the
* freight table is absent.
*/
export class RepairOtpEmailSchema2020000000000 implements MigrationInterface {
name = "RepairOtpEmailSchema2020000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("freight.otp_verifications");
if (!exists) return;
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ALTER COLUMN phone DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("freight.otp_verifications");
if (!exists) return;
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
DROP COLUMN IF EXISTS email
`);
}
}

View File

@@ -72,8 +72,14 @@ export class OtpService {
message: "OTP sent successfully", message: "OTP sent successfully",
}; };
} catch (error) { } catch (error) {
console.log(error); // Log the real cause (DB/SMS/email failure) with its stack so a deployed
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
this.logger.error(
`Failed to send OTP to ${target.email ?? target.phone}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
);
throw new BadRequestException("Failed to send OTP"); throw new BadRequestException("Failed to send OTP");
} }
} }

View File

@@ -16,7 +16,7 @@ import {
Group, Group,
Loader, Loader,
Paper, Paper,
Progress, RingProgress,
Stack, Stack,
Text, Text,
ThemeIcon, ThemeIcon,
@@ -26,7 +26,11 @@ import {
import { PageContainer } from "@/components/page"; import { PageContainer } from "@/components/page";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; import {
RouteCorridor,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { freightBrand } from "@/theme/freight-brand"; import { freightBrand } from "@/theme/freight-brand";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -52,8 +56,11 @@ function formatDateTime(iso?: string | null) {
}); });
} }
/** Compact icon + label + value cell used in the header meta strip. */ /**
function MetaStat({ * A single fact in the hero's glass meta strip — icon chip + uppercase label +
* value, laid on the translucent panel over the gradient.
*/
function HeroStat({
icon, icon,
label, label,
value, value,
@@ -63,15 +70,33 @@ function MetaStat({
value: string; value: string;
}) { }) {
return ( return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="edr-green"> <Box
style={{
width: 34,
height: 34,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
color: "white",
flexShrink: 0,
}}
>
{icon} {icon}
</ThemeIcon> </Box>
<Stack gap={0} style={{ minWidth: 0 }}> <Stack gap={1} style={{ minWidth: 0 }}>
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}> <Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
>
{label} {label}
</Text> </Text>
<Text size="sm" fw={700} c="dark.5" truncate> <Text size="sm" fw={700} c="white" truncate>
{value} {value}
</Text> </Text>
</Stack> </Stack>
@@ -79,6 +104,38 @@ function MetaStat({
); );
} }
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint: string;
}) {
return (
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} size="sm">
{title}
</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Stack>
</Group>
);
}
const CARD_STYLE = {
borderColor: scheduleBrand.mutedBorder,
boxShadow: scheduleBrand.shadowSm,
} as const;
export default function TrainScheduleTrackPage() { export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>(); const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast(); const { toast } = useToast();
@@ -116,9 +173,13 @@ export default function TrainScheduleTrackPage() {
const canLog = track.status === "DISPATCHED"; const canLog = track.status === "DISPATCHED";
const totalStations = track.stations.length; const totalStations = track.stations.length;
const reached = Math.min(track.currentSequenceNo + 1, totalStations); const reached = Math.min(track.currentSequenceNo + 1, totalStations);
const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; const progressPct =
totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
const clampedPct = Math.min(100, Math.max(0, progressPct)); const clampedPct = Math.min(100, Math.max(0, progressPct));
const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; const currentStation =
track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
const inTransit = track.status === "DISPATCHED";
const arrived = track.status === "ARRIVED";
const handleLog = (sequenceNo: number) => { const handleLog = (sequenceNo: number) => {
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
@@ -156,160 +217,259 @@ export default function TrainScheduleTrackPage() {
Back to schedule Back to schedule
</Button> </Button>
{/* Header */} {/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
<Paper radius="lg" withBorder p="lg"> <Paper
<Stack gap="lg"> radius="lg"
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md"> p={0}
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}> style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
<Box >
style={{ <Box
width: 48, style={{
height: 48, background: scheduleBrand.heroGradient,
borderRadius: 12, padding: "26px 28px",
display: "flex", position: "relative",
alignItems: "center", }}
justifyContent: "center", >
background: freightBrand.gradient, {/* soft decorative glow, purely artistic */}
color: "white", <Box
flexShrink: 0, style={{
}} position: "absolute",
> top: -80,
<Navigation size={24} /> right: -60,
</Box> width: 260,
<Stack gap={6} style={{ minWidth: 0 }}> height: 260,
<Group gap="sm" align="center" wrap="wrap"> borderRadius: "50%",
<Title order={3} fw={800}> background: "rgba(255,255,255,0.10)",
Train tracking pointerEvents: "none",
</Title> }}
{track.trainNumber ? ( />
<Badge variant="light" color="edr-green" radius="sm">
{track.trainNumber} <Group
</Badge> justify="space-between"
) : null} align="flex-start"
{track.direction ? ( wrap="wrap"
<Badge variant="light" color="gray" radius="sm"> gap="xl"
{track.direction} style={{ position: "relative" }}
</Badge> >
) : null} {/* left — identity + route */}
</Group> <Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
<Box maw={360}> <Group gap="md" align="center" wrap="nowrap">
<RouteCorridor origin={track.origin} destination={track.destination} variant="compact" /> <Box
style={{
width: 52,
height: 52,
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.26)",
color: "white",
flexShrink: 0,
}}
>
<Navigation size={26} />
</Box> </Box>
</Stack> <Stack gap={6} style={{ minWidth: 0 }}>
</Group> <Group gap="sm" align="center" wrap="wrap">
<StatusPill status={track.status} /> <Title order={3} fw={800} c="white">
</Group> Train tracking
</Title>
{track.trainNumber ? (
<Badge
variant="white"
color="dark"
radius="sm"
styles={{ root: { color: freightBrand.primaryDark } }}
>
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge
variant="outline"
radius="sm"
styles={{
root: {
color: "white",
borderColor: "rgba(255,255,255,0.5)",
},
}}
>
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={380}>
<RouteCorridor
origin={track.origin}
destination={track.destination}
variant="compact"
onDark
/>
</Box>
</Stack>
</Group>
{/* Journey progress */} <Group gap="sm">
<Box> <StatusPill status={track.status} size="md" />
<Group justify="space-between" mb={6}> <Box
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}> px={12}
Journey progress py={5}
</Text> style={{
<Text size="xs" fw={700} c="edr-green.8"> borderRadius: 999,
{reached} / {totalStations} stations · {Math.round(clampedPct)}% background: "rgba(255,255,255,0.16)",
</Text> border: "1px solid rgba(255,255,255,0.24)",
</Group> }}
<Progress >
value={clampedPct} <Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
size="lg" {arrived
radius="xl" ? "Journey complete"
color="edr-green" : inTransit
striped={track.status === "DISPATCHED"} ? `En route · ${currentStation}`
animated={track.status === "DISPATCHED"} : "Awaiting dispatch"}
/> </Text>
</Box> </Box>
</Group>
</Stack>
{/* Meta strip */} {/* right — progress ring, the artistic focal point */}
<Group justify="space-between" wrap="wrap" gap="lg"> <RingProgress
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} /> size={132}
<MetaStat thickness={11}
icon={<CalendarClock size={16} />} roundCaps
label="Departed" sections={[{ value: clampedPct, color: "white" }]}
value={formatDateTime(track.actualDepartureAt)} rootColor="rgba(255,255,255,0.22)"
/> label={
<MetaStat <Stack gap={0} align="center">
icon={<Flag size={16} />} <Text fw={800} fz={26} lh={1} c="white">
label="Arrived" {Math.round(clampedPct)}%
value={formatDateTime(track.actualArrivalAt)} </Text>
/> <Text
<MetaStat size="10px"
icon={<Train size={16} />} fw={700}
label="Stations" tt="uppercase"
value={`${reached} of ${totalStations}`} style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
>
{reached}/{totalStations} stops
</Text>
</Stack>
}
/> />
</Group> </Group>
</Stack> </Box>
{/* glass meta strip below the wash */}
<Group
justify="space-between"
wrap="wrap"
gap="lg"
px={28}
py="md"
style={{
background: freightBrand.primaryDark,
borderTop: "1px solid rgba(255,255,255,0.12)",
}}
>
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<HeroStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<HeroStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<HeroStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
/>
</Group>
</Paper> </Paper>
{/* Corridor */} {/* ── Route corridor ── */}
<Paper radius="lg" p="lg" withBorder> <Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Stack gap="md"> <Stack gap="md">
<Group gap="sm" align="center" wrap="nowrap"> <SectionHead
<ThemeIcon size={34} radius="md" variant="light" color="edr-green"> icon={<Navigation size={17} />}
<Navigation size={17} /> title="Route corridor"
</ThemeIcon> hint={
<Stack gap={0}> canLog
<Text fw={800} size="sm"> ? "Log the train passing each station; the final station marks arrival."
Route corridor : arrived
</Text> ? "This train has arrived at its destination."
<Text size="xs" c="dimmed"> : "Tracking becomes available once the train is dispatched."
{canLog }
? "Log the train passing each station; the final station marks arrival." />
: track.status === "ARRIVED"
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."}
</Text>
</Stack>
</Group>
<RouteCorridorTrack <RouteCorridorTrack
stations={track.stations} stations={track.stations}
currentSequenceNo={track.currentSequenceNo} currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints} checkpoints={track.checkpoints}
canLog={canLog} canLog={canLog}
loggingSeq={ loggingSeq={
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null recordCheckpoint.isPending
? recordCheckpoint.variables?.payload.sequenceNo
: null
} }
onLogCheckpoint={handleLog} onLogCheckpoint={handleLog}
/> />
</Stack> </Stack>
</Paper> </Paper>
{/* Checkpoint log */} {/* ── Checkpoint log ── */}
<Paper radius="lg" p="lg" withBorder> <Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Group gap="sm" align="center" wrap="nowrap" mb="md"> <Group justify="space-between" wrap="nowrap" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green"> <SectionHead
<CheckCircle2 size={17} /> icon={<CheckCircle2 size={17} />}
</ThemeIcon> title="Checkpoint log"
<Stack gap={0}> hint={`${track.checkpoints.length} event${
<Text fw={800} size="sm"> track.checkpoints.length === 1 ? "" : "s"
Checkpoint log } recorded`}
</Text> />
<Text size="xs" c="dimmed">
{track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
</Text>
</Stack>
</Group> </Group>
{track.checkpoints.length === 0 ? ( {track.checkpoints.length === 0 ? (
<Stack align="center" gap="xs" py="xl"> <Stack
<ThemeIcon size={44} radius="xl" variant="light" color="gray"> align="center"
<MapPin size={20} /> gap="xs"
py={40}
style={{
borderRadius: 14,
border: `1px dashed ${scheduleBrand.mutedBorder}`,
background: scheduleBrand.softSurface,
}}
>
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
<MapPin size={22} />
</ThemeIcon> </ThemeIcon>
<Text size="sm" fw={600} c="gray.7"> <Text size="sm" fw={700} c="gray.7">
No checkpoints yet No checkpoints yet
</Text> </Text>
<Text size="xs" c="dimmed" ta="center" maw={300}> <Text size="xs" c="dimmed" ta="center" maw={320}>
Each station the train passes will be logged here with its timestamp. Each station the train passes will be logged here with its
timestamp.
</Text> </Text>
</Stack> </Stack>
) : ( ) : (
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="edr-green"> <Timeline
active={track.checkpoints.length}
bulletSize={24}
lineWidth={2}
color="edr-green"
>
{track.checkpoints.map((cp) => ( {track.checkpoints.map((cp) => (
<Timeline.Item <Timeline.Item
key={cp.id} key={cp.id}
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />} bullet={
cp.kind === "ARRIVED" ? (
<CheckCircle2 size={13} />
) : (
<MapPin size={12} />
)
}
title={ title={
<Group gap="sm"> <Group gap="sm">
<Text fw={700} size="sm"> <Text fw={700} size="sm">
@@ -319,7 +479,13 @@ export default function TrainScheduleTrackPage() {
size="xs" size="xs"
radius="sm" radius="sm"
variant="light" variant="light"
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"} color={
cp.kind === "ARRIVED"
? "teal"
: cp.kind === "DEPARTED"
? "blue"
: "edr-green"
}
> >
{cp.kind} {cp.kind}
</Badge> </Badge>