diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 04edf4fba..78c15cba1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,7 +53,7 @@ jobs: NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" - + DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) if [ -z "$DEPLOYABLE" ]; then echo "Only non-deployable files changed. Skipping deploy." @@ -142,6 +142,31 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: ./scripts/deploy/create-npmrc.sh + - name: Resolve env file path for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + case "${{ matrix.service }}" in + passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;; + payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;; + esac + + - name: Build migration image for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + docker build \ + --secret id=npmrc,src=.npmrc \ + --target migration \ + -f "apps/edr-${{ matrix.service }}/Dockerfile" \ + -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ + . + + - name: Run migrations for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" + - name: Build ${{ matrix.service }} run: | set -euo pipefail diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 1e748cc51..ebec2323c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -18,10 +18,12 @@ import { Button, Card, Checkbox, + DateInput, Divider, Group, Menu, Modal, + NumberInput, Select, SimpleGrid, Stack, @@ -327,6 +329,10 @@ const FirstMilePage = () => { const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); const [bookingSearch, setBookingSearch] = useState(""); + const [distanceOpen, setDistanceOpen] = useState(false); + const [distanceValue, setDistanceValue] = useState(""); + const [distanceDate, setDistanceDate] = useState(null); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), queryFn: async () => { @@ -379,6 +385,21 @@ const FirstMilePage = () => { }, }); + const updateDistanceMutation = useMutation({ + mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) => + firstMileService.update(id, { exactKm }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + if (activeRecord) { + toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); + } + closeDistance(); + }, + onError: () => { + toast({ title: "Update failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -454,6 +475,29 @@ const FirstMilePage = () => { acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue }); }; + const openDistance = (id: string) => { + setActiveId(id); + setDistanceValue(""); + setDistanceDate(new Date()); + setDistanceOpen(true); + }; + + const closeDistance = () => { + setDistanceOpen(false); + setActiveId(null); + setDistanceValue(""); + setDistanceDate(null); + }; + + const handleSaveDistance = () => { + const distance = parseFloat(distanceValue); + if (!activeId || isNaN(distance) || distance < 0) { + toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + return; + } + updateDistanceMutation.mutate({ id: activeId, exactKm: distance }); + }; + const matchesFilter = (r: FirstMileRecord) => { switch (statusFilter) { case "ALL": return true; @@ -726,6 +770,11 @@ const FirstMilePage = () => { > View detail + openDistance(row.original.id)} + > + Add Actual distance + {canPrint && ( } @@ -1030,6 +1079,59 @@ const FirstMilePage = () => { )} + + {/* Add Actual Distance modal */} + Add Actual Distance} + size="md" + radius="lg" + centered + > + + {activeRecord && ( + + + {bookingRef(activeRecord)} + + Customer + {customerName(activeRecord)} + + + Est. Distance (KM) + {activeRecord.estimatedKm ?? "—"} + + + + )} + setDistanceValue(String(v ?? ""))} + min={0} + step={0.1} + decimalScale={2} + /> + + + + + + + ); }; diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 5fd647968..2b0ee8041 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -1,29 +1,25 @@ # syntax=docker/dockerfile:1 # Build from monorepo root: docker build -f apps/edr-passenger-api/Dockerfile . -# On start: runs prisma migrate deploy + seed, then the API. - +# On start: runs the API only — migrations/seed now run separately via the +# `migration` stage, invoked as a one-shot container in CI before deploy. FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat RUN corepack enable WORKDIR /app - FROM base AS pruner COPY . . RUN pnpm dlx turbo prune "@edr/passenger-api" --docker - FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile - FROM base AS builder COPY --from=installer /app/ . COPY --from=pruner /app/out/full/ . RUN pnpm --filter "@edr/passenger-api" exec prisma generate RUN pnpm turbo build --filter="@edr/passenger-api..." - FROM base AS deployer COPY --from=builder /app/ . RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy @@ -32,20 +28,24 @@ RUN if [ -d node_modules/.prisma ]; then \ cp -r node_modules/.prisma /deploy/node_modules/.prisma; \ fi +# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...` +# against the real DB, as its own gated step *before* the app image is built/deployed. +# Runs the same generate + migrate + seed sequence the old entrypoint used to run at +# container start, but now as a throwaway container, before the app container exists. +FROM deployer AS migration +WORKDIR /deploy +RUN corepack enable && corepack prepare pnpm@11.1.1 --activate +ENV CI=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"] + FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat -RUN corepack enable && corepack prepare pnpm@11.1.1 --activate ENV NODE_ENV=production WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs -COPY --from=deployer /deploy . -COPY apps/edr-passenger-api/docker-entrypoint.sh /docker-entrypoint.sh -RUN chmod +x /docker-entrypoint.sh \ - && chown -R nestjs:nodejs /app +COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs -ENV CI=true -ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 EXPOSE 4000 -ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["node", "dist/main.js"] diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index ea35c1f39..5cddf5232 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -1,30 +1,32 @@ # syntax=docker/dockerfile:1 -# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile . - +# Build from monorepo root: docker build -f apps/edr-payment-api/Dockerfile . FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat RUN corepack enable WORKDIR /app - FROM base AS pruner COPY . . RUN pnpm dlx turbo prune "@edr/payment-api" --docker - FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ pnpm install --frozen-lockfile - FROM base AS builder COPY --from=installer /app/ . COPY --from=pruner /app/out/full/ . RUN pnpm turbo build --filter="@edr/payment-api..." - FROM base AS deployer COPY --from=builder /app/ . RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy +# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...` +# against the real DB, as its own gated step *before* the app image is built/deployed. +# The runner below only ever runs `node dist/main.js` — migrations happen here, not there. +FROM deployer AS migration +WORKDIR /deploy +CMD ["node", "dist/scripts/migrate.js"] + FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat ENV NODE_ENV=production @@ -32,10 +34,6 @@ WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs COPY --from=deployer --chown=nestjs:nodejs /deploy . -COPY apps/edr-payment-api/docker-entrypoint.sh /docker-entrypoint.sh -RUN chmod +x /docker-entrypoint.sh \ - && chown -R nestjs:nodejs /app USER nestjs EXPOSE 3008 -ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["node", "dist/main.js"]