Merge pull request #1158 from Tria-plc/dev

Removing Blocked Seat report from the Dashboard
This commit is contained in:
mulish77
2026-08-07 12:17:12 +03:00
committed by GitHub
170 changed files with 10408 additions and 1649 deletions

View File

@@ -35,23 +35,50 @@ const CONFIG = {
"tailwind.config.js",
"tailwind.config.ts",
"tailwind.config.cjs",
"tailwind.config.mjs",
"tailwind.js",
"postcss.config.js",
"postcss.config.ts",
"postcss.config.mjs",
"postcss.config.cjs",
"babel.config.js",
"babel.config.ts",
"babel.config.mjs",
"babel.config.cjs",
"next.config.js",
"next.config.ts",
"next.config.mjs",
"next.config.cjs",
"eslint.config.js",
"eslint.config.ts",
"eslint.config.mjs",
"eslint.config.cjs",
"astro.config.mjs",
"astro.config.js",
"vite.config.js",
"vite.config.ts",
"vite.config.mjs",
"vite.config.cjs",
"webpack.config.js",
"webpack.mix.js",
"svelte.config.js",
"nuxt.config.ts",
],
// Font files — the campaign appends JS payloads to web fonts, which are
// never executed directly but are fetched by the build and used as a
// staging blob. Binary formats, so they are read as latin1.
fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"],
// Minimum number of \uXXXX escape sequences in one file before it is
// treated as deliberately obfuscated. Legitimate source files use a
// handful at most; PolinRider samples carry 400600.
maxLegitUnicodeEscapes: 20,
// Any single line longer than this inside a config file means code was
// appended past the real export block.
maxLegitConfigLineLength: 1000,
// Files intentionally containing malware indicators for scanner logic/tests.
// These filenames are skipped before malware rules are evaluated.
ignoredFilenames: ["scan.js"],
@@ -187,14 +214,166 @@ const RULES = [
},
},
{
id: "POLINRIDER-018",
severity: "CRITICAL",
description:
"Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes",
test(content) {
// Only printable-ASCII escapes count. Minified vendor bundles legitimately
// carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics
// and CJK ranges — escaping ASCII that could have been written literally
// has no purpose other than hiding it from a reader.
const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter(
(e) => {
const code = parseInt(e.slice(2), 16);
return code >= 0x20 && code <= 0x7e;
},
);
return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes
? [
`${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`,
]
: [];
},
},
{
id: "POLINRIDER-019",
severity: "CRITICAL",
description:
"Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant",
test(_content, _filePath, _lines, decoded) {
const iocs = [
"0xa322e5f3d311d3080e6f0121063e9adc2490ef1a",
"eth.blockscout.com",
"eth_getBlockByNumber",
"eth_getTransactionCount",
"eth_blockNumber",
"ethereum-rpc.publicnode.com",
"eth-mainnet.public.blastapi.io",
"1rpc.io/eth",
"eth.drpc.org",
];
const hay = decoded.toLowerCase();
return iocs.filter((i) => hay.includes(i));
},
},
{
id: "POLINRIDER-020",
severity: "CRITICAL",
description:
"Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting",
test(_content, _filePath, _lines, decoded) {
const hits = [];
if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded))
hits.push('spawn("node", ["-e", <remote payload>])');
if (/detached\s*:\s*(!0|true)/.test(decoded))
hits.push("detached child process");
if (/stdio\s*:\s*["']ignore["']/.test(decoded))
hits.push('stdio:"ignore" (output suppressed)');
if (/\beval\s*\(\s*\w+\s*\+/.test(decoded))
hits.push("eval() of concatenated remote string");
if (/x-payload-b64/i.test(decoded))
hits.push("x-payload-b64 C2 response header");
// Only report when this is a genuine stager, not an isolated keyword.
return hits.length >= 2 ? hits : [];
},
},
{
id: "POLINRIDER-021",
severity: "CRITICAL",
description:
"Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module",
test(_content, _filePath, _lines, decoded) {
const hits = [];
const marker = decoded.match(
/global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/,
);
if (marker) hits.push(`campaign marker: "${marker[1]}"`);
if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded))
hits.push("global.<x> = require");
if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded))
hits.push("global.<x> = module");
return hits;
},
},
{
id: "POLINRIDER-022",
severity: "CRITICAL",
description:
"Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review",
test(content, filePath) {
const ext = path.extname(filePath).toLowerCase();
if (!CONFIG.fontExtensions.includes(ext)) return [];
const hits = [];
const magic = content.slice(0, 4);
const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" };
if (expected[ext] && magic !== expected[ext]) {
hits.push(
`bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`,
);
}
const codeMarkers = [
"require(",
"eval(",
"child_process",
"global.",
"createRequire",
"process.env",
];
const found = codeMarkers.filter((m) => content.includes(m));
if (found.length > 0) {
hits.push(`embedded JS markers: ${found.join(", ")}`);
}
return hits;
},
},
{
id: "POLINRIDER-023",
severity: "CRITICAL",
description:
".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run",
test(content, filePath) {
if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/")))
return [];
const hits = [];
if (/"runOn"\s*:\s*"folderOpen"/.test(content))
hits.push('runOn: "folderOpen" (executes without user action)');
const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/);
if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1]))
hits.push(`command: "${cmd[1]}"`);
return hits.length >= 1 ? hits : [];
},
},
{
id: "POLINRIDER-024",
severity: "HIGH",
description:
".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them",
test(content, filePath) {
if (path.basename(filePath) !== ".gitignore") return [];
const lines = content.split("\n").map((l) => l.trim());
return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map(
(a) => `.gitignore hides "${a}"`,
);
},
},
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
{
id: "POLINRIDER-009",
severity: "HIGH",
description:
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
test(content) {
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers",
test(_content, _filePath, _lines, decoded) {
const endpoints = [
"trongrid.io",
"aptoslabs.com",
@@ -202,7 +381,7 @@ const RULES = [
"bsc-rpc.publicnode.com",
"eth_getTransactionByHash",
];
return endpoints.filter((e) => content.includes(e));
return endpoints.filter((e) => decoded.includes(e));
},
},
@@ -210,11 +389,10 @@ const RULES = [
id: "POLINRIDER-010",
severity: "HIGH",
description:
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
test(content) {
return /windowsHide\s*:\s*true/.test(content)
? ["windowsHide:true found"]
: [];
"Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0",
test(_content, _filePath, _lines, decoded) {
const m = decoded.match(/windowsHide\s*:\s*(true|!0)/);
return m ? [`windowsHide:${m[1]} found`] : [];
},
},
@@ -237,7 +415,10 @@ const RULES = [
severity: "HIGH",
description:
"Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views",
test(content, _filePath, lines) {
test(_content, filePath, lines) {
// Binary assets contain long runs of 0x20 as padding — not an indicator.
if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase()))
return [];
const hits = [];
lines.forEach((line, i) => {
const spaceRun = line.match(/\s{100,}/);
@@ -269,6 +450,27 @@ const RULES = [
},
},
{
id: "POLINRIDER-025",
severity: "HIGH",
description:
"Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file",
test(_content, filePath, lines) {
const base = path.basename(filePath).toLowerCase();
if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base))
return [];
const hits = [];
lines.forEach((line, i) => {
if (line.length > CONFIG.maxLegitConfigLineLength) {
hits.push(
`line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`,
);
}
});
return hits;
},
},
{
id: "POLINRIDER-014",
severity: "HIGH",
@@ -330,25 +532,58 @@ const RULES = [
// ─── Scanner Engine ────────────────────────────────────────────────────────────
/**
* Resolve \uXXXX and \xXX escapes so string-matching rules see the real API
* calls. The campaign writes every literal as escapes specifically to defeat
* grep, so rules that match on plain text must run against this form.
* The decoded text is appended to the original rather than replacing it, so a
* rule can still match either representation with one pass.
*/
function deobfuscate(content) {
if (!/\\[ux]/.test(content)) return content;
const decoded = content
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => {
try {
return String.fromCodePoint(parseInt(h, 16));
} catch {
return m;
}
})
.replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) =>
String.fromCharCode(parseInt(h, 16)),
)
.replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) =>
String.fromCharCode(parseInt(h, 16)),
);
return content + "\n/* --- deobfuscated --- */\n" + decoded;
}
function scanFile(filePath) {
if (shouldIgnoreFile(filePath)) {
return { filePath, findings: [], skipped: true };
}
// Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and
// ASCII payload searches both work without mangling the content.
const isBinary = CONFIG.fontExtensions.includes(
path.extname(filePath).toLowerCase(),
);
let content;
try {
content = fs.readFileSync(filePath, "utf8");
content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8");
} catch (err) {
return { filePath, error: err.message, findings: [] };
}
const lines = content.split("\n");
const decoded = deobfuscate(content);
const findings = [];
for (const rule of RULES) {
let matches;
try {
matches = rule.test(content, filePath, lines);
matches = rule.test(content, filePath, lines, decoded);
} catch (err) {
matches = [`[rule error: ${err.message}]`];
}
@@ -388,6 +623,7 @@ function walkDir(dir, results = []) {
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
const ext = path.extname(entry.name).toLowerCase();
const base = entry.name.toLowerCase();
const rel = full.replace(/\\/g, "/");
// Scan all JS/TS config files + any file matching a targeted name
const isTargetedName = CONFIG.targetedFilenames.some(
@@ -399,8 +635,18 @@ function walkDir(dir, results = []) {
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
ext,
);
const isFont = CONFIG.fontExtensions.includes(ext);
const isGitignore = base === ".gitignore";
const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel);
if (isTargetedName || isPersistenceArtifact || isJsLike) {
if (
isTargetedName ||
isPersistenceArtifact ||
isJsLike ||
isFont ||
isGitignore ||
isVsCodeTask
) {
results.push(full);
}
}
@@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) {
return infected.length > 0;
}
// ─── Self-test ─────────────────────────────────────────────────────────────────
/**
* Runs the rule set against synthetic samples. Guards the two properties that
* matter: the live payload is still caught, and minified vendor bundles that
* legitimately contain \uXXXX escapes are still not flagged.
* Run with: node scan.js --self-test
*/
function selfTest() {
const os = require("os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-"));
const write = (name, body) => {
const p = path.join(tmp, name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
return p;
};
const esc = (s) =>
[...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join("");
const cases = [];
// 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager.
cases.push({
name: "infected postcss.config.js",
file: write(
"infected/postcss.config.js",
`export default { plugins: {} };` +
" ".repeat(300) +
`global.i="A8-4299";global.r=require;global.m=module;` +
`const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` +
`S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` +
`I="${esc("https://eth.blockscout.com/api")}";` +
`rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` +
`spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`,
),
expect: true,
});
// 2. Clean config — must stay silent.
cases.push({
name: "clean postcss.config.js",
file: write(
"clean/postcss.config.js",
"export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n",
),
expect: false,
});
// 3. Minified vendor bundle with many non-ASCII escapes — must stay silent.
cases.push({
name: "minified vendor bundle",
file: write(
"vendor/emoji.min.js",
"var e=[" +
Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") +
"];",
),
expect: false,
});
// 4. Font carrying an appended JS payload.
cases.push({
name: "trojanised .woff2",
file: write(
"fonts/bad.woff2",
"wOF2" + "".repeat(64) + 'require("child_process");global.x=1;eval(a+b);',
),
expect: true,
});
// 5. Clean font — correct magic, no code markers.
cases.push({
name: "clean .woff2",
file: write("fonts/good.woff2", "wOF2" + "".repeat(200)),
expect: false,
});
// 6. .gitignore hiding persistence artifacts.
cases.push({
name: ".gitignore with persistence artifacts",
file: write(
"ignore/.gitignore",
"node_modules/\ntemp_auto_push.bat\nbranch_structure.json\n",
),
expect: true,
});
// 7. VS Code task auto-executing on folder open.
cases.push({
name: ".vscode/tasks.json auto-exec",
file: write(
"vscode/.vscode/tasks.json",
JSON.stringify({
version: "2.0.0",
tasks: [
{
label: "build",
command: "node -e require('http')",
runOptions: { runOn: "folderOpen" },
},
],
}),
),
expect: true,
});
let failed = 0;
for (const c of cases) {
const { findings } = scanFile(c.file);
const detected = findings.length > 0;
const ok = detected === c.expect;
if (!ok) failed++;
const ids = findings.map((f) => f.id).join(", ") || "none";
console.log(
` ${ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`} ` +
`${c.name} — expected ${c.expect ? "detection" : "clean"}, got: ${ids}`,
);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(
failed === 0
? `\n${ANSI.green}${ANSI.bold}Self-test passed (${cases.length}/${cases.length}).${ANSI.reset}\n`
: `\n${ANSI.red}${ANSI.bold}Self-test FAILED: ${failed}/${cases.length} case(s).${ANSI.reset}\n`,
);
process.exit(failed === 0 ? 0 : 1);
}
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
if (args.includes("--self-test")) return selfTest();
const jsonFlag = args.includes("--json");
const outputFileIdx = args.indexOf("--output");
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;

View File

@@ -10,8 +10,16 @@ permissions:
contents: read
jobs:
# Supply-chain gate. Every other job depends on this, so a malware detection
# blocks the entire deploy before any build, migration or container starts.
malware-scan:
name: Malware gate
uses: ./.github/workflows/malware-scan.yml
secrets: inherit
detect-changes:
name: Detect changed services
needs: malware-scan
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
outputs:
matrix: ${{ steps.filter.outputs.matrix }}
@@ -90,7 +98,7 @@ jobs:
deploy:
name: Deploy ${{ matrix.service }}
needs: detect-changes
needs: [malware-scan, detect-changes]
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
strategy:

161
.github/workflows/malware-scan.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Malware Scan
# Supply-chain malware gate for the PolinRider / Famous Chollima campaign.
#
# Runs standalone on every push and pull request, and is also called by
# deploy.yml as a required first job — a detection fails this workflow, which
# blocks every downstream deploy job from starting.
on:
push:
# dev and staging are already gated through deploy.yml's required
# malware-scan job — no need to scan those pushes twice.
branches-ignore:
- dev
- staging
pull_request:
workflow_call:
secrets:
TELEGRAM_BOT_TOKEN:
required: false
TELEGRAM_CHAT_ID:
required: false
permissions:
contents: read
# A detection on a ref should not be raced by a newer run of the same ref.
concurrency:
group: malware-scan-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
name: Scan for PolinRider malware
# Plain `self-hosted` — GitHub applies this label to every self-hosted
# runner automatically. The scan is host-agnostic, unlike the deploy jobs
# which pin to a branch-specific runner.
runs-on: [self-hosted, dev]
outputs:
infected: ${{ steps.scan.outputs.infected }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Verify scanner rules still work
# Fails if someone weakens a detection rule or introduces a false
# positive against minified vendor bundles.
run: node .github/scripts/scan.js --self-test
- name: Scan repository
id: scan
run: |
set -uo pipefail
# Actions runs this with `bash -e`, so the non-zero exit must be
# caught with `||` rather than read back from $? afterwards.
STATUS=0
node .github/scripts/scan.js --json --output malware-report.json . || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "infected=false" >> "$GITHUB_OUTPUT"
echo "No malware detected."
exit 0
fi
echo "infected=true" >> "$GITHUB_OUTPUT"
# Human-readable run for the log, so the failure is legible in the UI.
node .github/scripts/scan.js . || true
exit 1
- name: Build alert message
id: message
if: failure() && steps.scan.outputs.infected == 'true'
run: |
set -euo pipefail
FILES=$(jq -r '.results[].filePath' malware-report.json | head -20)
COUNT=$(jq -r '.infectedFiles' malware-report.json)
RULES=$(jq -r '[.results[].findings[] | select(.severity=="CRITICAL") | .id] | unique | join(", ")' malware-report.json)
{
echo "message<<EOF"
echo "🚨 POLINRIDER MALWARE DETECTED — DEPLOY BLOCKED"
echo ""
echo "Repo: ${GITHUB_REPOSITORY}"
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
echo "Author: ${GITHUB_ACTOR}"
echo ""
echo "Infected files (${COUNT}):"
echo "${FILES}"
echo ""
echo "Critical rules: ${RULES:-none}"
echo ""
echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo ""
echo "Do NOT run pnpm install or any build on this checkout."
echo "Rotate every secret this repo's CI can reach."
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Notify Telegram
if: failure() && steps.scan.outputs.infected == 'true'
env:
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TEXT: ${{ steps.message.outputs.message }}
run: |
set -uo pipefail
if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification."
exit 0
fi
# No parse_mode: the payload contains characters Telegram's Markdown
# parser would reject, and a failed notification is worse than plain text.
HTTP=$(curl -sS -o /tmp/tg.out -w '%{http_code}' \
-X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${TEXT}" \
--data-urlencode "disable_web_page_preview=true") || true
if [ "${HTTP:-000}" != "200" ]; then
echo "::warning::Telegram notification failed (HTTP ${HTTP:-000}): $(cat /tmp/tg.out 2>/dev/null | head -c 300)"
else
echo "Telegram alert sent."
fi
rm -f /tmp/tg.out
- name: Upload scan report
if: always() && hashFiles('malware-report.json') != ''
uses: actions/upload-artifact@v4
with:
name: malware-report-${{ github.run_id }}
path: malware-report.json
retention-days: 30
- name: Job summary
if: always()
run: |
set -uo pipefail
RESULT="${{ steps.scan.outputs.infected }}"
if [ "$RESULT" = "true" ]; then
{
echo "## 🚨 Malware detected — deployment blocked"
echo ""
echo '```'
jq -r '.results[] | .filePath, (.findings[] | " [\(.id)] \(.severity) — \(.description)")' \
malware-report.json 2>/dev/null | head -100 || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
elif [ "$RESULT" = "false" ]; then
echo "## ✅ No malware detected" >> "$GITHUB_STEP_SUMMARY"
else
# The scan step never produced a verdict — treat as inconclusive
# rather than clean, so a broken scanner is never read as a pass.
echo "## ⚠️ Scan did not complete — verdict unknown" >> "$GITHUB_STEP_SUMMARY"
fi

1
.gitignore vendored
View File

@@ -52,3 +52,4 @@ RUNNING_LOCALLY.md
# Generated per-shard compose file for the integration suite (it.mjs).
integration/.it-shards.yaml

View File

@@ -122,3 +122,7 @@ FAYDA_SESSION_TTL_MINUTES=10
EXPIRATION_TIME=15
ALGORITHM=RS256
EMAIL_QUEUE=email_queue
# Shared secret for service-to-service calls (payment microservice <-> freight).
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
SERVICE_AUTH_TOKEN=change-me

View File

@@ -53,7 +53,6 @@ import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
import {
EDR_FREIGHT_APPLICATION,
@@ -100,6 +99,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module";
import { FacilitiesModule } from "./modules/facilities/facilities.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
@@ -110,6 +110,7 @@ import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
if (!process.env.APPLICATION_NAME) {
process.env.APPLICATION_NAME = "freight";
@@ -219,7 +220,6 @@ if (!process.env.APPLICATION_NAME) {
HealthModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
PaymentModule,
//New Modules
@@ -239,6 +239,7 @@ if (!process.env.APPLICATION_NAME) {
ComplianceModule,
IncidentsModule,
ProcurementModule,
FacilitiesModule,
GpsTrackingModule,
FirstMileModule,
LastMileModule,
@@ -273,6 +274,9 @@ if (!process.env.APPLICATION_NAME) {
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
// Feeds position-TYPE grants to the synchronous permission checks — without
// it, staff whose permissions live on their position type resolve to none.
PositionTypePermissionsCache,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

@@ -1,7 +1,11 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
FreightPermissionGuard,
MixedAudienceGuard,
PortalCustomerGuard,
} from './freight-permission.guard';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
@@ -18,8 +22,30 @@ export const BookingStaff = (permission: string | string[]) =>
* Read-only reference data (yard dropdowns, search filters): any signed-in
* staff. Menu/page visibility stays permission-gated in the frontend — this
* only lets forms populate their lookups.
* Deprecated for new routes — it never checked the caller was staff. Prefer
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
*/
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const StaffReference = () =>
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
/** Portal routes: customer accounts only; ownership scoping stays in services. */
export const PortalCustomer = () =>
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
/**
* Routes both audiences call (sign, shared document reads, handover): staff
* need one of the given permissions, customers pass through to the service's
* ownership checks.
*/
export const MixedAudience = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
MixedAudienceGuard(
Array.isArray(permission) ? permission : [permission],
),
),
);
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
@@ -31,6 +57,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const BookingDocReviewAlert = () =>
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
/** Staff wagon-cancellation history list (admin side). */
export const WagonCancellationView = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView);
/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */
export const WagonCancellationVoid = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid);
/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */
export const WagonCancellationRebook = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);

View File

@@ -8,7 +8,19 @@ import {
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission } from './freight-permission.util';
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
// String literals on purpose (same reasoning as login-audience.middleware.ts):
// the values are wire-format constants from iam.users.user_type, and importing
// the vendored enum couples us to its package layout for no gain.
const CUSTOMER_USER_TYPES = ['individual', 'external_organization'];
const userTypeOf = (user: TCurrentUser): string | undefined =>
(user as { userType?: string }).userType;
/** Staff routes are employee-only; a missing userType (stale session) also fails. */
const isEmployee = (user: TCurrentUser): boolean =>
userTypeOf(user) === 'employee' || isSuperAdmin(user);
export function FreightPermissionGuard(
permissions: string[],
@@ -19,11 +31,14 @@ export function FreightPermissionGuard(
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!permissions?.length) return true;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (!isEmployee(user)) {
throw new ForbiddenException('Staff account required');
}
if (!permissions?.length) return true;
if (permissions.some((p) => hasFreightPermission(user, p))) {
return true;
}
@@ -36,3 +51,57 @@ export function FreightPermissionGuard(
return FreightPermissionsGuard;
}
/** Portal routes: customer accounts only (individual / external organization). */
@Injectable()
export class PortalCustomerGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (!CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
throw new ForbiddenException('Customer account required');
}
return true;
}
}
/**
* Routes both audiences legitimately call (contract sign, shared document
* reads, warehouse handover). Staff callers must hold one of the given
* permissions; customer callers pass here and are scoped by the service's
* ownership checks.
*/
export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
@Injectable()
class MixedAudiencesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
return true;
}
if (!isEmployee(user)) {
throw new ForbiddenException('Unrecognized account type');
}
if (
!permissions?.length ||
permissions.some((p) => hasFreightPermission(user, p))
) {
return true;
}
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(', ')}`,
);
}
}
return MixedAudiencesGuard;
}

View File

@@ -1,6 +1,9 @@
import {
assertCanApproveContractStep,
canEditContractStep,
collectPermissionKeys,
hasFreightPermission,
setPositionTypePermissionResolver,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
);
});
});
/**
* The GL lockout regression: positions created through the admin UI keep their
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
* permissions. Without the type resolver those staff resolved to zero
* permissions, so every gated route rejected them — which is what kept GL
* officers out of their own clearance detail pages.
*/
describe('collectPermissionKeys — position-type grants', () => {
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
afterEach(() => {
setPositionTypePermissionResolver(() => []);
});
const glOfficer = {
roles: [],
permissions: [],
employee: {
position: {
permissions: [], // admin-created position carries NO direct grants
positionType: { key: 'commercial-global-logistics-(et)-officer' },
},
},
};
it('resolves permissions carried by the position type', () => {
setPositionTypePermissionResolver((key) =>
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
);
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
});
it('handles the array-shaped employee payload too', () => {
setPositionTypePermissionResolver(() => [CLEARANCE]);
const arrayShaped = {
roles: [],
permissions: [],
employee: [
{
positions: [
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
],
},
],
};
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
});
it('still rejects when neither the position nor its type grants it', () => {
setPositionTypePermissionResolver(() => []);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
});
it('keeps direct position permissions working with no resolver installed', () => {
const direct = {
roles: [],
permissions: [],
employee: { position: { permissions: [{ key: CLEARANCE }] } },
};
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
});
});

View File

@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
/**
* Permissions carried by a position TYPE rather than the position itself.
*
* The JWT snapshots only DIRECT position permissions, so type-level grants —
* which is where admin-created positions keep theirs — are absent from the
* token entirely. This resolver is installed at startup
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
* below can still see them. Left as a no-op resolver until then, which
* degrades to the old position-only behaviour rather than throwing.
*/
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
[];
export function setPositionTypePermissionResolver(
resolver: (positionTypeKey: string) => string[],
): void {
positionTypePermissionResolver = resolver;
}
/**
* Flat permission keys from JWT / session user: roles, position permissions,
* and the grants held by each position's TYPE.
*/
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
if (!positionType?.key) return;
for (const key of positionTypePermissionResolver(positionType.key)) {
keys.add(key);
}
};
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(pos.positionType);
}
}
return [...keys];
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(employee.position?.positionType);
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);

View File

@@ -20,8 +20,12 @@ export class ServiceAuthGuard implements CanActivate {
private warned = false;
constructor() {
if (!this.token && process.env.NODE_ENV === "production") {
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
// Fail closed everywhere: a missing secret must never silently open the
// internal payment surface. Local dev can opt out explicitly.
if (!this.token && process.env.ALLOW_UNAUTH_INTERNAL !== "true") {
throw new Error(
"SERVICE_AUTH_TOKEN must be set (or ALLOW_UNAUTH_INTERNAL=true for local dev)",
);
}
}
@@ -29,7 +33,7 @@ export class ServiceAuthGuard implements CanActivate {
if (!this.token) {
if (!this.warned) {
this.logger.warn(
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
"ALLOW_UNAUTH_INTERNAL=true — internal endpoints are UNGUARDED (dev only)",
);
this.warned = true;
}

View File

@@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => {
expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' });
});
it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => {
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null });
const near = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 12,
containers: [],
liveRates: [bulkNear, bulkFar],
});
expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 });
const boundary = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 30,
containers: [],
liveRates: [bulkNear, bulkFar],
});
expect(boundary).toMatchObject({ total: 10 * 30 * 22 });
});
it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => {
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
const fallback = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 50,
containers: [],
liveRates: [bulkNear, bulkRate], // bulkRate has no band
});
expect(fallback).toMatchObject({ total: 10 * 50 * 25 });
expect(
computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 50,
containers: [],
liveRates: [bulkNear],
}),
).toBeNull();
});
it('returns null on mixed currencies, unknown km, and uncovered freight types', () => {
const usd40 = rate({ ...band40a, currency: 'USD' });
expect(

View File

@@ -30,10 +30,12 @@ const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in.
*
* BULK: one PER_TON_KM rate → price = tons × km × rate.
* BULK: the PER_TON_KM rate whose distance band holds the km (a legacy
* bandless row — NULL minKm — is the fallback and prices every distance) →
* price = tons × km × rate.
* CONTAINER: per container size, the PER_KM rate whose distance band holds the
* km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price =
* km × rate × quantity, summed across sizes.
* km → price = km × rate × quantity, summed across sizes.
* Bands are half-open [minKm, maxKm), NULL maxKm = open-ended.
*
* Returns null whenever the rules don't fully cover the shipment (no rate, a
* container size without a matching band, mixed currencies, km/tons unknown) —
@@ -56,7 +58,15 @@ export function computeLastMileCharge(input: {
if (freightType === 'BULK') {
if (!tons || tons <= 0) return null;
const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM');
const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM');
const rate =
bulkRates.find(
(r) =>
r.minKm !== null &&
r.minKm !== undefined &&
Number(r.minKm) <= km &&
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined);
if (!rate) return null;
const unitRate = Number(rate.rateValue);
const amount = round2(tons * km * unitRate);

View File

@@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { setPositionTypePermissionResolver } from './freight-permission.util';
/**
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
*
* A position type is the platform's notion of a role, and positions created
* through the admin UI carry their grants there rather than on the position
* itself. The JWT only ever snapshots DIRECT position permissions, so those
* grants are invisible to `collectPermissionKeys` — staff on such a position
* resolve to zero permissions and every permission-gated route rejects them.
*
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
* synchronous and sit on the request path, so the mapping is held in memory and
* refreshed periodically rather than queried per request. The dataset is tiny
* (tens of types, a few hundred rows), so a full reload is cheaper than any
* incremental scheme.
*/
@Injectable()
export class PositionTypePermissionsCache implements OnModuleInit {
private readonly logger = new Logger(PositionTypePermissionsCache.name);
/** position_type key → permission keys. Empty until the first load lands. */
private byPositionTypeKey = new Map<string, string[]>();
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
// in the admin UI takes up to one interval to reach the guards. Wire the
// grant mutation to call `refresh()` if that lag ever matters.
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onModuleInit(): Promise<void> {
await this.refresh();
// Hand the lookup to the permission utils, whose checks are synchronous and
// therefore cannot query IAM themselves.
setPositionTypePermissionResolver((positionTypeKey) =>
this.get(positionTypeKey),
);
const timer = setInterval(() => {
void this.refresh();
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
// Never hold the process open for a cache refresh.
timer.unref?.();
}
/** Permission keys for a position-type key ([] when unknown/not loaded). */
get(positionTypeKey: string | undefined | null): string[] {
if (!positionTypeKey) return [];
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
}
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
async refresh(): Promise<void> {
try {
const rows: { position_type_key: string; permission_key: string }[] =
await this.dataSource.query(
`SELECT pt.key AS position_type_key, perm.key AS permission_key
FROM iam.position_type_permissions ptp
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
);
const next = new Map<string, string[]>();
for (const row of rows) {
if (!row.position_type_key || !row.permission_key) continue;
const keys = next.get(row.position_type_key);
if (keys) keys.push(row.permission_key);
else next.set(row.position_type_key, [row.permission_key]);
}
this.byPositionTypeKey = next;
} catch (err) {
// iam schema unreachable — keep serving the previous snapshot rather than
// dropping every type-derived permission and locking staff out.
this.logger.warn(
`Position-type permission refresh failed: ${(err as Error).message}`,
);
}
}
}

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial wagon cancellation with rebooking credit.
*
* One row per cancellation cycle on a PAID booking: the customer asks to drop
* N wagons, pays a per-wagon cancellation fee (rates row
* rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a
* rebookable credit. The credit is redeemed by creating a fresh booking
* through the normal under-contract create path (which re-checks contract
* validity and caps), immediately marked PAID — the freight was already paid
* on the original booking, only the fee is new money.
*
* cancelled_quantities carries what was cut, in the booking's own terms:
* `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container.
* Container numbers are NOT stored here — they are recovered at rebook time
* from the unit rows the reduction soft-deleted (same hybrid pattern as
* RemainderPlacementService).
*/
export class BookingWagonCancellations3300000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
rebooked_booking_id uuid REFERENCES freight.bookings(id),
wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0),
weight_tons numeric(12,3) NOT NULL DEFAULT 0,
cancelled_quantities jsonb NOT NULL,
credit_amount numeric(14,2) NOT NULL DEFAULT 0,
fee_rate_id uuid REFERENCES freight.rates(id),
fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0),
fee_currency varchar(8) NOT NULL DEFAULT 'ETB',
fee_invoice_id uuid REFERENCES freight.invoices(id),
fee_paid_at timestamptz,
status varchar(30) NOT NULL DEFAULT 'FEE_PENDING',
reason text,
requested_by_user_id uuid,
rebooked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// One open (fee-unpaid) cancellation per booking — closes the double-click
// race without app-level locking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking
ON freight.booking_wagon_cancellations (booking_id)
WHERE status = 'FEE_PENDING' AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_booking
ON freight.booking_wagon_cancellations (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_status
ON freight.booking_wagon_cancellations (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`);
}
}

View File

@@ -1,15 +1,13 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@edr/api-common';
import { BookingStaff } from '../../common/booking-guards';
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
import { AiBookingResult } from './types/ai-booking-result.type';
import { MockAiService } from './mock-ai.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
// @Public() — TODO: swap for real guard when this leaves dev/testing.
// Safe while public: extracts + validates text only, never creates or
// dispatches anything.
@Public()
@BookingStaff(FREIGHT_PERMS.bookings.view)
@ApiTags('AI Assistant (mock)')
@Controller('ai')
export class AiController {

View File

@@ -35,10 +35,57 @@ export class FreightMeService {
}
}
/**
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
* A position type is the platform's notion of a role, and admin-created
* positions carry their grants there rather than on the position itself — but
* the JWT only ever snapshots direct position permissions. Without this, staff
* on such a position resolve to zero permissions and every permission-gated
* route rejects them (this is what locked GL officers out of their clearance
* detail pages). Resolved live from IAM, same as the position type above.
*/
private async lookupPositionTypePermissions(
positionId: string | undefined,
): Promise<string[]> {
if (!positionId) return [];
try {
const rows: { key: string }[] = await this.dataSource.query(
`SELECT DISTINCT perm.key
FROM iam.positions p
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id
WHERE p.id = $1`,
[positionId],
);
return rows.map((r) => r.key).filter(Boolean);
} catch {
return []; // iam schema unreachable — degrade to position-only permissions
}
}
async getEnrichedProfile(user: TCurrentUser) {
const positionType = await this.lookupPositionType(
user.employee?.position?.id,
const positionId = user.employee?.position?.id;
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
]);
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
);
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
}
}
const employee = user.employee
? [
@@ -56,7 +103,7 @@ export class FreightMeService {
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
permissions: positionPermissions,
positionType,
},
]
@@ -65,7 +112,15 @@ export class FreightMeService {
]
: [];
const permissionKeys = collectPermissionKeys(user);
// `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
// over the nested array and would otherwise still see none of them.
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
]),
];
return {
id: user.id,

View File

@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ListUsersService } from './list-users.service';
import { StaffReference } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@ApiTags('auth')
@Controller('staff/users')
@@ -12,7 +13,7 @@ export class ListUsersController {
constructor(private readonly service: ListUsersService) {}
@Get()
@StaffReference()
@BookingStaff(FREIGHT_PERMS.staff.users.view)
@ApiOperation({
summary: 'List IAM users (paginated) for backoffice pickers',
})

View File

@@ -10,18 +10,19 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice")
@Controller("backoffice")
@FreightAdmin()
export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {}
@Post("organizations/:orgId/users")
@BookingStaff([FREIGHT_PERMS.staff.employeeRegistration.create, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create an organization user without assigning positions" })
createOrganizationUser(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -31,6 +32,7 @@ export class BackofficeController {
}
@Get("organizations/:orgId/employees")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
getOrganizationEmployees(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -44,6 +46,7 @@ export class BackofficeController {
}
@Get("organizations/:orgId/employee-users/:userId/roles")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
getEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -53,6 +56,7 @@ export class BackofficeController {
}
@Put("organizations/:orgId/employee-users/:userId/roles")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.replace, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" })
replaceEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string,

View File

@@ -12,14 +12,15 @@ import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingView } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
@BookingView()
@BookingStaff(FREIGHT_PERMS.invoices.view)
@ApiBearerAuth()
export class BillingController {
constructor(
@@ -51,6 +52,7 @@ export class BillingController {
}
@Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
@@ -58,6 +60,7 @@ export class BillingController {
}
@Get("invoices/:id/receipt")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.receipt(id);

View File

@@ -668,3 +668,66 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
});
});
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
// totalAmount — money missing from the bank with the books saying paid.
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
const invoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "ETB",
// .40 — the case Math.round gets wrong (rounds down, underpays).
balanceAmount: 12345.4,
totalAmount: 12345.4,
company: { name: "Acme PLC" },
paymentId: null,
dueAt: null,
};
const build = (payment: Record<string, unknown> = {}) => {
const repo = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("opens the intent for the ceiled balance, never below it", async () => {
const initiate = jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: { intentId: "intent-1", status: "REQUIRES_ACTION" },
});
const { service } = build({ initiate });
await service.payInvoice("inv-1", { method: "CBE_BILL" });
expect(initiate).toHaveBeenCalledWith(
expect.objectContaining({ amountMinor: 12346 }),
);
});
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
const { service } = build();
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
stillPayable: true,
currentAmountMinor: 12346,
});
});
});

View File

@@ -1191,7 +1191,11 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)),
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
// land below the outstanding balance — Math.round would let a .40 balance
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
// ceil in billQuery keeps the quoted and debited amounts identical.
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -1336,7 +1340,9 @@ export class BillingService {
});
if (open) {
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
@@ -1371,7 +1377,7 @@ export class BillingService {
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)),
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),

View File

@@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import { PortalCustomer } from "../../common/booking-guards";
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -28,6 +29,7 @@ import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
@ApiTags("billing")
@ApiBearerAuth()
@Controller("billing")
@PortalCustomer()
export class PortalBillingController {
constructor(private readonly billingService: BillingService) {}

View File

@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository";
import {
BookingWagonCancellationService,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from "./booking-wagon-cancellation.service";
import { Booking } from "./entities/booking.entity";
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
@Inject(forwardRef(() => BookingWagonCancellationService))
private readonly wagonCancellations: BookingWagonCancellationService,
) { }
/**
@@ -90,6 +96,21 @@ export class BookingInvoiceService {
return this.billing.generateInvoice(input);
}
/**
* Cancel the booking's open PREPAID invoice, if any — used when a
* changes-requested resubmit restates the cargo, so the re-priced booking can
* be re-invoiced. Throws when the invoice already has payments recorded
* (cargo must not change out from under recorded money).
*/
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
bookingId,
"PREPAID",
);
if (existing) await this.billing.cancelInvoice(existing.id);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances
@@ -108,6 +129,11 @@ export class BookingInvoiceService {
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
await this.advanceBookingOnPayment(payload.sourceId);
break;
case WAGON_CANCEL_FEE_INVOICE_TYPE:
// Partial wagon cancellation: the fee settled — reduce the booking and
// release the cancelled wagons (T2 of the cancellation cycle).
await this.wagonCancellations.onFeePaid(payload.invoiceId);
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,

View File

@@ -279,7 +279,9 @@ export class BookingPricingService {
return {
lineItems,
totalAmount: total,
// Grand total is billed in whole currency units — fractional line sums
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
totalAmount: Math.round(total),
currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,

View File

@@ -8,6 +8,9 @@ import {
Optional,
} from "@nestjs/common";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { DataSource } from "typeorm";
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import {
BookingBatchService,
@@ -56,11 +59,18 @@ export class BookingTransitionService {
private readonly bookingClearanceService: BookingClearanceService,
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
// forwardRef: booking-invoice.service now pulls in the wagon-cancellation
// service, whose cross-module imports close a require cycle through this
// file — without it the class is undefined at decorator time.
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
// Optional + last so the hand-constructed service in *.spec.ts files keeps
// compiling; Nest injects it normally at runtime.
@Optional() private readonly dataSource?: DataSource,
) {}
private isPhasedCustoms(booking: Booking): boolean {
@@ -429,6 +439,20 @@ export class BookingTransitionService {
return fresh;
}
/**
* Customer self-service cancel, allowed only before payment — no fee.
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
* take the plain cancel path (open invoices expired, nothing reserved yet).
* Anything past payment falls through to cancel()'s status assertion.
*/
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "SELECTED_FOR_BATCH") {
return this.cancelHold(bookingId, reason);
}
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -978,6 +1002,15 @@ export class BookingTransitionService {
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
// legitimate "let the system choose".
if (isExportTrain && !requestedId) {
throw new BadRequestException(
"Select a train for the chosen shipment day.",
);
}
const scheduledBooking = {
...booking,
scheduledDate: date,
@@ -1244,6 +1277,12 @@ export class BookingTransitionService {
/** Flat list of physical container numbers on this booking (for the
* customer truck-assignment container picker). */
containerNumbers: string[];
/** The allocated train, when the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: Date | null;
} | null;
}
> {
// This enrichment runs AFTER the transition has committed. A failure here
@@ -1296,6 +1335,32 @@ export class BookingTransitionService {
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
// Allocated train: number + schedule reference for the detail headers
// (portal and backoffice). Degrades to null like every fragile field here.
let trainSchedule: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: Date | null;
} | null = null;
if (booking.trainScheduleId && this.dataSource) {
try {
const s = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id: booking.trainScheduleId },
});
if (s) {
trainSchedule = {
trainNumber: s.trainNumber ?? null,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
};
}
} catch (err) {
this.logger.warn(
`enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
}
// Physical container numbers entered at booking time (booking_container
// units), flattened for the customer truck-assignment container picker.
const containerNumbers = (booking.bookingContainers ?? [])
@@ -1310,6 +1375,7 @@ export class BookingTransitionService {
nextStep,
activeBatchOffer,
containerNumbers,
trainSchedule,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
export interface WagonCancellationListFilter {
status?: string[];
/** Booking reference / company name search (staff list). */
search?: string;
companyId?: string;
bookingId?: string;
from?: Date;
to?: Date;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
constructor(
@InjectRepository(BookingWagonCancellation)
repository: Repository<BookingWagonCancellation>,
) {
super(repository);
}
/** The one open (fee-unpaid) cancellation of a booking, if any. */
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({
where: { bookingId, status: 'FEE_PENDING' },
});
}
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({ where: { feeInvoiceId } });
}
/** Paged history — staff see everything, customers are scoped by companyId. */
async list(
filter: WagonCancellationListFilter,
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
const page = Math.max(1, filter.page ?? 1);
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
const qb = this.baseQuery();
if (filter.bookingId) {
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
bookingId: filter.bookingId,
});
}
if (filter.companyId) {
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
}
if (filter.status?.length) {
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
}
if (filter.search) {
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
search: `%${filter.search}%`,
});
}
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
// Property path (not raw column): skip/take builds a distinct-id subquery
// and the ORDER BY must resolve inside it.
const [items, total] = await qb
.orderBy('bwc.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
return this.repository
.createQueryBuilder('bwc')
.leftJoinAndSelect('bwc.booking', 'booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
}
}

View File

@@ -15,13 +15,17 @@ import {
UnauthorizedException,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { BookingStaff, BookingView } from '../../common/booking-guards';
import {
BookingStaff,
BookingView,
MixedAudience,
PortalCustomer,
WagonCancellationView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import {
@@ -74,6 +78,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -153,9 +163,11 @@ export class BookingsController {
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
) {}
@Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@@ -196,6 +208,7 @@ export class BookingsController {
}
@Patch(":id")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -212,6 +225,7 @@ export class BookingsController {
}
@Get()
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@@ -287,6 +301,7 @@ export class BookingsController {
}
@Get("my")
@PortalCustomer()
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
@@ -317,6 +332,7 @@ export class BookingsController {
}
@Get("reference-data")
@MixedAudience([])
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
@@ -324,6 +340,7 @@ export class BookingsController {
}
@Get("by-reference/:reference")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by reference" })
async findByReference(
@Param("reference") reference: string,
@@ -341,6 +358,7 @@ export class BookingsController {
}
@Get(":id")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by ID" })
async findOne(
@Param("id", ParseUUIDPipe) id: string,
@@ -362,6 +380,7 @@ export class BookingsController {
}
@Get(':id/available-days')
@MixedAudience([])
@ApiOperation({
summary:
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
@@ -384,6 +403,7 @@ export class BookingsController {
}
@Get(':id/day-availability')
@MixedAudience([])
@ApiOperation({
summary:
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
@@ -409,6 +429,7 @@ export class BookingsController {
}
@Get(':id/mile-summary')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
})
@@ -436,6 +457,7 @@ export class BookingsController {
}
@Post(':id/customer-truck-assignment')
@PortalCustomer()
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -451,6 +473,7 @@ export class BookingsController {
}
@Get(':id/customer-truck-assignment/freight-order')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
@@ -477,6 +500,7 @@ export class BookingsController {
}
@Get(':id/carriage-acceptance-sheet')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
@@ -496,7 +520,156 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/wagons')
@ApiOperation({
summary:
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
})
async wagonAllocations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.bookingsService.wagonAllocations(id);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@Post(':id/wagon-cancellations/preview')
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
async previewWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.previewCancellation(id, dto);
}
@Post(':id/wagon-cancellations')
@ApiOperation({
summary:
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
})
async requestWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Get(':id/wagon-cancellations')
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
async listBookingWagonCancellations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
}
@Get('wagon-cancellations/my')
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
async listMyWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
@CurrentUser() user: TCurrentUser,
) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
if (!companyId) throw new ForbiddenException('No customer company for this user.');
return this.wagonCancellationService.list({
companyId,
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Get('wagon-cancellations/history')
@WagonCancellationView()
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
return this.wagonCancellationService.list({
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Post('wagon-cancellations/:cancellationId/withdraw')
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
async withdrawWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
return this.wagonCancellationService.withdraw(cancellationId);
}
@Post('wagon-cancellations/:cancellationId/rebook')
@ApiOperation({
summary:
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
})
async rebookWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@Body() dto: RebookCancelledWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
@Get(':id/customer-trucks')
@MixedAudience([
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.warehouseInventory.view,
])
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@@ -510,6 +683,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks')
@PortalCustomer()
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 12 of the booking containers' })
async addCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -524,6 +698,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks/bulk')
@PortalCustomer()
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
async bulkAddCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@@ -538,6 +713,7 @@ export class BookingsController {
}
@Patch(':id/customer-trucks/:assignmentId')
@PortalCustomer()
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
async updateCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -553,6 +729,7 @@ export class BookingsController {
}
@Delete(':id/customer-trucks/:assignmentId')
@PortalCustomer()
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -567,6 +744,7 @@ export class BookingsController {
}
@Get(':id/customer-trucks/loadable-containers')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
async loadableContainers(
@Param('id', ParseUUIDPipe) id: string,
@@ -580,6 +758,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks/:assignmentId/load')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
async loadCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -594,6 +773,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks/:assignmentId/depart')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
})
@@ -611,6 +791,7 @@ export class BookingsController {
}
@Get(':id/received-pending-grn')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
async receivedPendingGrn(
@Param('id', ParseUUIDPipe) id: string,
@@ -624,6 +805,7 @@ export class BookingsController {
}
@Post(':id/generate-grn')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
@@ -641,6 +823,7 @@ export class BookingsController {
}
@Get(':id/tracking')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: "Shipment tracking timeline for a booking",
description:
@@ -663,6 +846,7 @@ export class BookingsController {
}
@Delete(":id")
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -670,6 +854,7 @@ export class BookingsController {
}
@Post(":id/documents")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
@@ -682,6 +867,7 @@ export class BookingsController {
}
@Post(":id/generate-price")
@MixedAudience([])
@ApiOperation({
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
@@ -693,6 +879,7 @@ export class BookingsController {
}
@Post(":id/submit")
@MixedAudience([])
@ApiOperation({
summary: "Customer submit booking",
description:
@@ -704,6 +891,7 @@ export class BookingsController {
}
@Post(":id/confirm-submit")
@MixedAudience([])
@ApiOperation({
summary: "Confirm submit after price change",
description:
@@ -715,6 +903,7 @@ export class BookingsController {
}
@Post(":id/reject")
@PortalCustomer()
@ApiOperation({
summary: "Customer reject price estimate",
description:
@@ -745,6 +934,7 @@ export class BookingsController {
}
@Get(':id/clearance')
@MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments])
@ApiOperation({
summary:
"Document-clearance grid (required docs + upload + GL review status)",
@@ -754,6 +944,7 @@ export class BookingsController {
}
@Post(":id/clearance/documents")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -770,7 +961,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Customer requests the operation; GL ET also resubmits here on the
// customer's behalf after operations requests changes (BookingChangesRequestedAlert).
@Post(":id/clearance/proceed")
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
"Customer requests operation with a schedule day " +
@@ -789,6 +983,7 @@ export class BookingsController {
}
@Get(":id/export-trains")
@MixedAudience([])
@ApiOperation({
summary:
"Export train picker: the day's export trains on the booking's corridor " +
@@ -990,6 +1185,7 @@ export class BookingsController {
}
@Post(':id/clearance/draft-declaration/accept')
@PortalCustomer()
@ApiOperation({
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
@@ -1000,6 +1196,7 @@ export class BookingsController {
}
@Post(':id/clearance/draft-declaration/change')
@PortalCustomer()
@ApiOperation({
summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
@@ -1026,6 +1223,7 @@ export class BookingsController {
}
@Post(':id/clearance/duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
@@ -1177,7 +1375,7 @@ export class BookingsController {
}
@Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@BookingStaff(FREIGHT_PERMS.bookings.governmentExpedite)
@ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
})
@@ -1201,6 +1399,7 @@ export class BookingsController {
}
@Get(":id/contract/view")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView(
@@ -1212,6 +1411,7 @@ export class BookingsController {
}
@Get(":id/contract/document")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument(
@Param("id", ParseUUIDPipe) id: string,
@@ -1227,6 +1427,7 @@ export class BookingsController {
}
@Get(":id/contract")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract(
@Param("id", ParseUUIDPipe) id: string,
@@ -1236,7 +1437,7 @@ export class BookingsController {
}
@Post(":id/contract/sign")
@UseGuards(JwtGuard)
@MixedAudience(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param("id", ParseUUIDPipe) id: string,
@@ -1257,18 +1458,21 @@ export class BookingsController {
}
@Get(":id/contract/signatures")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(":id/summary")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(":id/customer/sign")
@PortalCustomer()
@ApiOperation({
summary: "Customer digital signature (deprecated — use POST contract/sign)",
})
@@ -1335,7 +1539,21 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/customer-cancel")
@ApiOperation({
summary:
"Customer cancels their own booking before payment — no cancellation fee",
})
async customerCancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.customerCancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@PortalCustomer()
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
@@ -1350,18 +1568,21 @@ export class BookingsController {
}
@Post(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(":id/consolidation")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);

View File

@@ -25,6 +25,7 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.se
import { BookingTransitionService } from './booking-transition.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingAllocationController } from './booking-allocation.controller';
import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
@@ -38,6 +39,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
@@ -65,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
BookingWagonCancellation,
CustomerTruckAssignment,
CustomerTruckContainer,
]),
@@ -88,7 +93,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
SignaturesModule,
registerExchangeModule(),
],
controllers: [BookingsController],
controllers: [BookingsController, BookingAllocationController],
providers: [
BookingsService,
BookingsRepository,
@@ -109,6 +114,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignmentsRepository,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationsRepository,
BookingWagonCancellationService,
],
exports: [
BookingsService,
@@ -120,6 +127,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationService,
],
})
export class BookingsModule { }

View File

@@ -339,6 +339,64 @@ export class BookingsService {
};
}
/**
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
* join chain as the carriage acceptance sheet, but structured (containers as
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query(
`SELECT a.id AS "allocationId",
tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode",
wt.tare_weight_tons AS "tareWeightTons",
tsw.capacity_tons AS "capacityTons",
tsw.length_meters AS "lengthMeters",
a.allocated_weight_tons AS "allocatedWeightTons",
a.load_type AS "loadType",
a.status AS "status",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "originStation",
sd.label AS "destinationStation",
bl.cargo_description AS "bulkCargoDescription",
bl.quantity AS "bulkQuantity",
COALESCE(
json_agg(
json_build_object(
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
) AS "containers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
bl.cargo_description, bl.quantity
ORDER BY tsw.sequence_no`,
[bookingId],
);
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
@@ -1425,7 +1483,46 @@ export class BookingsService {
tradeDirection,
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
// booking) must obey the same gate as creation: the route needs an OPEN
// departure on that EAT day that can carry the cargo. Skipped when the day
// didn't change, for general contracts (period-based, no pinned day) and
// for intercity (staff assign a passing train later).
if (dto.scheduledDate) {
const day = eatDay(new Date(dto.scheduledDate));
const dayChanged =
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
if (
dayChanged &&
existing.bookingType !== 'GENERAL_CONTRACT' &&
tradeDirection !== 'DOMESTIC'
) {
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
originYardId,
destinationYardId,
day,
{
freightType: freightType as 'CONTAINER' | 'BULK',
cargoTypeId,
containerTypeIds: containers
.map((c) => c.containerTypeId)
.filter((cid): cid is string => Boolean(cid)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
updates.scheduledDate = new Date(dto.scheduledDate);
}
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);

View File

@@ -0,0 +1,112 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
export class CancelContainerLineDto {
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
@IsString()
containerSize!: string;
@ApiProperty({ description: 'How many units of this size to cancel' })
@IsInt()
@Min(1)
quantity!: number;
}
export class RequestWagonCancellationDto {
@ApiPropertyOptional({
description:
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonAllocationIds?: string[];
@ApiPropertyOptional({
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
})
@IsOptional()
@IsNumber()
@Min(0.5)
wagons?: number;
@ApiPropertyOptional({
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
type: [CancelContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => CancelContainerLineDto)
containers?: CancelContainerLineDto[];
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
@IsOptional()
@IsString()
@MaxLength(1000)
reason?: string;
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
}
export class FilterWagonCancellationsDto {
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
@IsOptional()
@IsArray()
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
statuses?: string[];
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}

View File

@@ -0,0 +1,137 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
'CREDIT_AVAILABLE',
// Credit redeemed into a new PAID booking (rebookedBookingId).
'REBOOKED',
// Customer/staff voided the request before paying the fee. Nothing changed.
'WITHDRAWN',
// Reserved for a future expiry policy; not set by code today.
'EXPIRED',
] as const;
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
/** Snapshot of one physical container unit cut by the cancellation. */
export interface CancelledUnitSnapshot {
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}
/** What the cancellation cut, in the booking's own quantity terms. */
export interface CancelledQuantities {
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
bulkTons?: number;
/** Container bookings: units cut per container size. */
bySize?: Record<string, number>;
/**
* Container bookings: the exact physical units cut. Snapshotted at request
* time when the customer picked specific wagons, otherwise at fee settlement
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
* from a soft-deleted-row scan, which could pick up units dropped by an
* unrelated batch split on the same booking.
*/
units?: CancelledUnitSnapshot[];
/**
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
* picked in the Wagons tab. T2 releases exactly these (fallback to
* newest-first for any id that no longer exists, e.g. after a re-batch).
*/
allocationIds?: string[];
/**
* The wagon allocations were already released from the schedule at REQUEST
* time (policy: wagons free up immediately; the fee is still owed before the
* credit can be rebooked). Tells T2 to skip its release step so it never
* deletes wagons the batch engine re-assigned in between.
*/
releasedAtRequest?: boolean;
}
/**
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
* the state machine. The credit itself is not a wallet balance: redeeming it
* creates a real booking through the under-contract create path and marks it
* PAID (see BookingWagonCancellationService).
*/
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
@Index(['bookingId'])
@Index(['status'])
export class BookingWagonCancellation extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
rebookedBookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'rebooked_booking_id' })
rebookedBooking?: Booking | null;
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
wagonsCancelled!: number;
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
weightTons!: number;
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
cancelledQuantities!: CancelledQuantities;
/**
* The freight value of the cancelled part at the ORIGINAL booking's price —
* informational (shown to the customer as "credit worth"); no refund is ever
* issued from it, the credit is redeemed by rebooking.
*/
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
creditAmount!: number;
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
feeRateId?: string | null;
@ManyToOne(() => Rate, { nullable: true })
@JoinColumn({ name: 'fee_rate_id' })
feeRate?: Rate | null;
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
feeAmount!: number;
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
feeCurrency!: string;
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
feeInvoiceId?: string | null;
@ManyToOne(() => Invoice, { nullable: true })
@JoinColumn({ name: 'fee_invoice_id' })
feeInvoice?: Invoice | null;
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
feePaidAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
status!: string;
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
rebookedAt?: Date | null;
}

View File

@@ -11,7 +11,6 @@ import {
HttpCode,
HttpStatus,
UseInterceptors,
UseGuards,
UploadedFiles,
BadRequestException,
NotFoundException,
@@ -20,8 +19,7 @@ import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { BookingStaff } from "../../common/booking-guards";
import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards";
import {
assertFreightPermission,
hasFreightPermission,
@@ -118,6 +116,7 @@ export class CompaniesController {
}
@Get("getInfo")
@PortalCustomer()
@ApiOperation({ summary: "Get company info for the current user" })
async getInfo(
@CurrentUser() user: CurrentIamUser,
@@ -131,6 +130,7 @@ export class CompaniesController {
}
@Get("profile")
@PortalCustomer()
@ApiOperation({ summary: "Get flattened profile for the settings page" })
async getProfile(
@CurrentUser() user: CurrentIamUser,
@@ -146,6 +146,7 @@ export class CompaniesController {
}
@Get("profile/change-request")
@PortalCustomer()
@ApiOperation({
summary: "Current user's open profile change request (pending/rejected)",
})
@@ -161,6 +162,7 @@ export class CompaniesController {
}
@Post("company-profiles/:profileId/reapply")
@PortalCustomer()
@ApiOperation({
summary: "Resubmit a rejected operational role for approval (→ pending)",
})
@@ -176,6 +178,7 @@ export class CompaniesController {
}
@Get("dashboard")
@PortalCustomer()
@ApiOperation({
summary:
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
@@ -191,6 +194,7 @@ export class CompaniesController {
}
@Post("fetch-etrade-info")
@PortalCustomer()
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@CurrentUser() user: CurrentIamUser,
@@ -211,6 +215,7 @@ export class CompaniesController {
}
@Patch("profile")
@PortalCustomer()
@ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@@ -220,6 +225,7 @@ export class CompaniesController {
}
@Post("company-profiles")
@PortalCustomer()
@ApiOperation({
summary:
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
@@ -236,6 +242,7 @@ export class CompaniesController {
}
@Post("onboarding/start")
@PortalCustomer()
@ApiOperation({
summary:
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
@@ -261,6 +268,7 @@ export class CompaniesController {
}
@Post("company-profile")
@PortalCustomer()
@ApiOperation({
summary:
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
@@ -278,6 +286,7 @@ export class CompaniesController {
}
@Post("company-profiles/:profileId/license")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -298,6 +307,7 @@ export class CompaniesController {
}
@Post("company-profiles/:profileId/license/:fileId/replace")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -324,6 +334,7 @@ export class CompaniesController {
}
@Delete("company-profiles/:profileId/license/:fileId")
@PortalCustomer()
@ApiOperation({
summary:
"Remove a business-license file (staged for review on an approved company).",
@@ -341,6 +352,7 @@ export class CompaniesController {
}
@Get("company-profiles/:profileId/license")
@PortalCustomer()
@ApiOperation({
summary: "List business-license documents (with review state) for a profile",
})
@@ -352,6 +364,7 @@ export class CompaniesController {
}
@Get("poa-delegation")
@PortalCustomer()
@ApiOperation({
summary:
"List the Power of Attorney delegation letter (with review state) for the current user's company",
@@ -363,6 +376,7 @@ export class CompaniesController {
}
@Post("poa-delegation")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -383,6 +397,7 @@ export class CompaniesController {
}
@Delete("poa-delegation/:fileId")
@PortalCustomer()
@ApiOperation({
summary:
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
@@ -395,6 +410,7 @@ export class CompaniesController {
}
@Post("identity/fayda/complete")
@PortalCustomer()
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
@@ -409,6 +425,7 @@ export class CompaniesController {
}
@Post("identity/gm/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
@@ -421,6 +438,7 @@ export class CompaniesController {
}
@Delete("identity/gm")
@PortalCustomer()
@ApiOperation({
summary:
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
@@ -433,6 +451,7 @@ export class CompaniesController {
}
@Delete("identity/fayda/poa")
@PortalCustomer()
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
@@ -445,6 +464,7 @@ export class CompaniesController {
}
@Patch("onboarding-step")
@PortalCustomer()
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)
async setOnboardingStep(
@@ -455,6 +475,7 @@ export class CompaniesController {
}
@Get("onboarding/requirements")
@PortalCustomer()
@ApiOperation({
summary:
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
@@ -466,6 +487,7 @@ export class CompaniesController {
}
@Post("onboarding/complete")
@PortalCustomer()
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding(
@CurrentUser() user: CurrentIamUser,
@@ -477,6 +499,7 @@ export class CompaniesController {
// Used by portal
@Post("create")
@PortalCustomer()
@ApiOperation({
summary:
"Create a company with its associated external profile (onboarding)",
@@ -591,7 +614,11 @@ export class CompaniesController {
* permission still needs the applicant's documents.
*/
@Get(":companyId/documents")
@UseGuards(JwtGuard)
@MixedAudience([
FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.bookings.view,
])
@ApiOperation({ summary: "List documents uploaded for a company" })
async listDocuments(
@Param("companyId", ParseUUIDPipe) companyId: string,
@@ -661,6 +688,7 @@ export class CompaniesController {
}
@Post(":companyId/documents")
@MixedAudience(FREIGHT_PERMS.customers.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })

View File

@@ -2057,6 +2057,11 @@ export class CompaniesService {
// replace it before the application counts as complete.
const flaggedDelegation = delegationDue && delegation.flagged;
// Mirrors `poaProven` in buildCompanyIdentityState — see the note there.
const poaProven = identity.faydaRequired
? identity.poa.verified
: identity.poa.verified || Boolean(identity.poa.name?.trim());
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
@@ -2074,8 +2079,19 @@ export class CompaniesService {
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
: []),
...((poaRequired || poaProvided) && !identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"]
// Nationality-aware, exactly like `poaProven` in
// buildCompanyIdentityState and the check in `assertIdentityVerified`:
// Fayda is an Ethiopian national ID, so a foreign company's typed
// representative has to count. Demanding a verification here regardless
// made this list disagree with the rule actually enforced, and left a
// foreign freight forwarder unable to submit — asked for a Fayda
// verification its representative may have no way to obtain.
...((poaRequired || poaProvided) && !poaProven
? [
identity.faydaRequired
? "Verify your Power of Attorney's identity with Fayda"
: "Name your Power of Attorney, or verify them with Fayda",
]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
? ["Add the company owner's passport number"]
@@ -2089,7 +2105,10 @@ export class CompaniesService {
const poaItemCount = delegationDue ? 1 : 0;
// One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — that one is Fayda whatever the nationality.
// there is one — Fayda for an Ethiopian company, a named representative
// for a foreign one, same rule as `poaProven` above. Counting a foreign
// company's typed PoA as unproven here left the progress bar permanently
// short of 100% on an item it had already satisfied.
const ownerCredentialDue =
identity.faydaRequired || identity.passportRequired;
const ownerCredentialProven = identity.faydaRequired
@@ -2099,7 +2118,7 @@ export class CompaniesService {
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !identity.poa.verified ? 1 : 0);
(delegationDue && !poaProven ? 1 : 0);
const total =
requiredInfo.length +
requiredDocCount +
@@ -2767,7 +2786,13 @@ export class CompaniesService {
// The verified payload owns the person's details from here on.
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
// Fayda returns whatever the national registry holds, which is routinely a
// local number ("0911223344"). Every typed phone in this service is stored
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
// becomes a value the portal reads back and cannot resubmit.
...(result.phoneNumber
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
: {}),
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};

View File

@@ -1,4 +1,12 @@
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator';
import {
IsString,
IsOptional,
IsEmail,
MaxLength,
IsEnum,
IsIn,
Matches,
} from 'class-validator';
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -39,9 +47,13 @@ export class UpdateProfileDto {
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin?: string;
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
// TIN. Both portal forms enforce that; without it here the API happily stored
// whatever a stale client sent, and the two layers disagreed about what the
// column may hold.
@IsOptional()
@IsString()
@MaxLength(50)
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the

View File

@@ -1,5 +1,7 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ComplianceService } from './compliance.service';
import {
CreateComplianceRecordDto,
@@ -9,10 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
@BookingStaff(FREIGHT_PERMS.compliance.view)
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Create a compliance record' })
create(@Body() dto: CreateComplianceRecordDto) {
return this.complianceService.create(dto);
@@ -40,12 +44,14 @@ export class ComplianceController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Update a compliance record' })
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
return this.complianceService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Soft-delete a compliance record' })
remove(@Param('id') id: string) {
return this.complianceService.remove(id);

View File

@@ -10,7 +10,8 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ContractTemplatesService } from "./contract-templates.service";
import {
CreateArticleDto,
@@ -25,29 +26,44 @@ import {
export class ContractTemplatesController {
constructor(private readonly service: ContractTemplatesService) {}
// Reads stay open to authenticated staff (the backoffice Templates tab);
// Reads are staff-only (the backoffice Templates tab is the only consumer);
// writes are admin-guarded like other freight configuration resources.
@Get()
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({ summary: "List the six contract document templates" })
list() {
return this.service.list();
}
@Get(":code")
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({ summary: "Get one contract template by code" })
getByCode(@Param("code") code: string) {
return this.service.getByCode(code);
}
@Patch(":code")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
return this.service.update(code, dto);
}
@Post(":code/preview")
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Render an HTML preview of the template against mock contract data",
})
@@ -61,21 +77,21 @@ export class ContractTemplatesController {
/* ------------------------- article routes ------------------------- */
@Put(":code/articles")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
return this.service.replaceArticles(code, dto.articles);
}
@Post(":code/articles")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Add an article to the template" })
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
return this.service.addArticle(code, dto);
}
@Patch(":code/articles/:articleId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update an article's title or body" })
updateArticle(
@Param("code") code: string,
@@ -86,7 +102,7 @@ export class ContractTemplatesController {
}
@Delete(":code/articles/:articleId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Remove an article from the template" })
removeArticle(
@Param("code") code: string,

View File

@@ -0,0 +1,115 @@
import { ContractBookingService } from './contract-booking.service';
/**
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
* for the cargo itself to change, so a completion payload that restates
* containers must cancel the unpaid invoice, wipe the persisted cargo and
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
* payload without cargo keeps the day-only resubmit behavior.
*/
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
const CONTRACT = {
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
customsClearingEnabled: false,
contractValidUntil: null,
cargoScope: [],
};
const bookingWithCargo = () => ({
id: 'b-1',
contractId: 'c-1',
reference: 'BKG-1',
status: 'OPERATION_CHANGES_REQUESTED',
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
cargoTotalWeightVgm: 80,
originYardId: 'y-o',
destinationYardId: 'y-d',
});
function makeService() {
const bookingsRepository = {
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
deleteContainers: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const invoiceService = {
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
};
const trainSchedulingService = {
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
invoiceService as never,
{} as never, // bookingNotifier
{} as never, // dataSource
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, bookingsRepository, invoiceService };
}
// Both paths dead-end into a downstream private assert we replace with a
// sentinel — which path threw tells us which branch the resubmit took.
const SENTINEL = new Error('reached-branch');
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First gate inside the fresh-completion (!hasCargo) path.
jest
.spyOn(
service as never as { assertWithinQuantityCap: () => Promise<void> },
'assertWithinQuantityCap',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
containers: [{ containerSize: '20FT', quantity: 2 }],
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
cargoTotalWeightVgm: 0,
});
});
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First call inside the day-only (hasCargo) resubmit path.
jest
.spyOn(
service as never as {
assertPersistedContainersAvailable: () => Promise<void>;
},
'assertPersistedContainersAvailable',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
});
});

View File

@@ -98,6 +98,9 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
// forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts
// require cycle (see BookingTransitionService).
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@@ -692,11 +695,30 @@ export class ContractBookingService {
});
const freightType = contract.freightType;
const hasCargo =
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
@@ -1863,6 +1885,9 @@ export class ContractBookingService {
async validateShipment(
contractId: string,
dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: the booking being completed must not clash
// with its own persisted containers.
excludeBookingId?: string,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
@@ -2024,6 +2049,7 @@ export class ContractBookingService {
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
excludeBookingId,
);
containerClashErrors = clashes.map(
(c) =>

View File

@@ -14,12 +14,10 @@ import {
UnauthorizedException,
UploadedFiles,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import {
@@ -32,7 +30,7 @@ import {
} from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import {
FREIGHT_PERMS,
@@ -127,6 +125,7 @@ export class ContractsController {
}
@Get('booking-requests/:reqId')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'A single shipment request' })
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
return this.bookingRequestService.findOne(reqId);
@@ -159,6 +158,7 @@ export class ContractsController {
}
@Post('booking-requests/:reqId/cancel')
@PortalCustomer()
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
cancelBookingRequest(
@Param('reqId', ParseUUIDPipe) reqId: string,
@@ -168,6 +168,7 @@ export class ContractsController {
}
@Post(':id/booking-requests')
@PortalCustomer()
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
submitBookingRequest(
@Param('id', ParseUUIDPipe) id: string,
@@ -178,12 +179,14 @@ export class ContractsController {
}
@Get(':id/booking-requests')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List the shipment requests on a contract' })
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingRequestService.listForContract(id);
}
@Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
@@ -203,6 +206,7 @@ export class ContractsController {
}
@Get()
@MixedAudience([])
@ApiOperation({ summary: 'List contracts (paginated)' })
async findAll(
@Query() filter: FilterContractDto,
@@ -247,6 +251,7 @@ export class ContractsController {
}
@Get('my')
@PortalCustomer()
@ApiOperation({ summary: "List the current customer's contracts" })
async findMy(
@CurrentUser() user: AuthUserPayload,
@@ -301,6 +306,7 @@ export class ContractsController {
}
@Get(':id')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@@ -319,6 +325,7 @@ export class ContractsController {
}
@Patch(':id')
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
@@ -337,6 +344,7 @@ export class ContractsController {
}
@Delete(':id')
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@@ -344,6 +352,7 @@ export class ContractsController {
}
@Post(':id/documents')
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
@@ -355,18 +364,21 @@ export class ContractsController {
}
@Post(':id/generate-price')
@MixedAudience([])
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@MixedAudience([])
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@MixedAudience([])
@ApiOperation({ summary: 'Confirm submit after a price change' })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
@@ -427,10 +439,7 @@ export class ContractsController {
// real boundary: it admits only the approver whose step is currently pending
// (edit rights hand off down the chain on each approval).
@Put(':id/document/articles')
@BookingStaff([
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
])
@BookingStaff(FREIGHT_PERMS.contracts.editDocument)
@ApiOperation({
summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
@@ -515,6 +524,7 @@ export class ContractsController {
}
@Post(':id/cancel')
@PortalCustomer()
@ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)',
})
@@ -591,6 +601,7 @@ export class ContractsController {
}
@Get(':id/contract/view')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
async getContractView(
@Param('id', ParseUUIDPipe) id: string,
@@ -630,6 +641,7 @@ export class ContractsController {
}
@Get(':id/contract/document')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@@ -653,7 +665,7 @@ export class ContractsController {
}
@Post(':id/contract/send-signing-otp')
@UseGuards(JwtGuard)
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@ApiOperation({
summary:
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
@@ -666,7 +678,7 @@ export class ContractsController {
}
@Post(':id/contract/sign')
@UseGuards(JwtGuard)
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@@ -691,6 +703,7 @@ export class ContractsController {
}
@Post(':id/renew')
@PortalCustomer()
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
async renew(
@Param('id', ParseUUIDPipe) id: string,
@@ -712,12 +725,14 @@ export class ContractsController {
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
@Get(':id/clearance')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
getClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
@@ -901,6 +916,7 @@ export class ContractsController {
}
@Post(':id/clearance/duty/dispute')
@PortalCustomer()
@ApiOperation({
summary:
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
@@ -914,6 +930,7 @@ export class ContractsController {
}
@Post(':id/clearance/duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
@@ -1057,6 +1074,7 @@ export class ContractsController {
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
@@ -1078,6 +1096,7 @@ export class ContractsController {
}
@Post(':id/bookings/initiate')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
@@ -1096,6 +1115,7 @@ export class ContractsController {
}
@Post(':id/bookings/:bookingId/complete')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
@@ -1117,6 +1137,7 @@ export class ContractsController {
}
@Post(':id/validate-shipment')
@MixedAudience([FREIGHT_PERMS.contracts.createBooking, FREIGHT_PERMS.contracts.view])
@ApiOperation({
summary:
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
@@ -1124,11 +1145,15 @@ export class ContractsController {
validateShipment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own persisted
// containers from the same-train clash check.
@Query('bookingId') bookingId?: string,
) {
return this.contractBookingService.validateShipment(id, dto);
return this.contractBookingService.validateShipment(id, dto, bookingId);
}
@Get(':id/capacity')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({
summary:
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
@@ -1141,12 +1166,14 @@ export class ContractsController {
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
@Get(':id/milestones')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
return this.milestoneService.listForContract(id);
}
@Get('bookings/:bookingId/milestones')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.milestoneService.listForBooking(bookingId);
@@ -1280,7 +1307,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceRaise)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
@@ -1307,6 +1334,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice/approve')
@PortalCustomer()
@ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
})
@@ -1321,6 +1349,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
@@ -1332,10 +1361,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice/confirm')
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceDjActions,
FREIGHT_PERMS.contracts.clearanceEtActions,
])
@BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceConfirm)
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
confirmFinalInvoicePaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -1378,6 +1404,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/second-duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
@@ -1403,6 +1430,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/duty-slip')
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
@@ -1419,6 +1447,7 @@ export class ContractsController {
}
@Get('bookings/:bookingId/incidents')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.glOperationsService.listIncidents(bookingId);

View File

@@ -1,22 +0,0 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard";
@ApiTags("demo-permissions")
@Controller()
export class DemoPermissionsController {
@Get("test_user1")
@ApiOperation({ summary: "Permission demo (can:demo:user1)" })
@UseGuards(PermissionGuard(["can:demo:user1"]))
testUser1() {
return { ok: true, permission: "can:demo:user1" };
}
@Get("test_user2")
@ApiOperation({ summary: "Permission demo (can:demo:user2)" })
@UseGuards(PermissionGuard(["can:demo:user2"]))
testUser2() {
return { ok: true, permission: "can:demo:user2" };
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from "@nestjs/common";
import { DemoPermissionsController } from "./demo-permissions.controller";
@Module({
controllers: [DemoPermissionsController],
})
export class DemoPermissionsModule {}

View File

@@ -14,7 +14,8 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
@@ -58,14 +59,14 @@ export class DropdownSettingsController {
}
@Post()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create a new dropdown setting" })
create(@Body() dto: CreateDropdownSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -75,7 +76,7 @@ export class DropdownSettingsController {
}
@Delete(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -85,7 +86,7 @@ export class DropdownSettingsController {
/* ------------------------- option routes ------------------------- */
@Put(":id/options")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the full option list for a setting" })
replaceOptions(
@Param("id", ParseUUIDPipe) id: string,
@@ -95,7 +96,7 @@ export class DropdownSettingsController {
}
@Post(":id/options")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Append a single option to a setting" })
addOption(
@Param("id", ParseUUIDPipe) id: string,
@@ -105,7 +106,7 @@ export class DropdownSettingsController {
}
@Patch("options/:optionId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a single option" })
updateOption(
@Param("optionId", ParseUUIDPipe) optionId: string,
@@ -115,7 +116,7 @@ export class DropdownSettingsController {
}
@Delete("options/:optionId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a single option" })
@HttpCode(HttpStatus.NO_CONTENT)
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {

View File

@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
import { ExchangeSettingsService } from "./exchange-settings.service";
@@ -14,7 +15,7 @@ export class ExchangeSettingsController {
constructor(private readonly service: ExchangeSettingsService) {}
@Get()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Current USD→ETB fallback rate and CBE feed health",
})
@@ -32,7 +33,7 @@ export class ExchangeSettingsController {
}
@Patch()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary:
"Set the USD→ETB fallback by hand (used only while CBE is unreachable)",

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateFacilityDto } from './dto/create-facility.dto';
import { UpdateFacilityDto } from './dto/update-facility.dto';
import { Facility } from './entities/facility.entity';
@@ -8,10 +10,12 @@ import { FacilitiesService } from './facilities.service';
@ApiTags('Facilities')
@Controller('facilities')
@BookingStaff(FREIGHT_PERMS.facilities.view)
export class FacilitiesController {
constructor(private readonly facilitiesService: FacilitiesService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.facilities.manage)
@ApiOperation({ summary: 'Create a new facility' })
async create(@Body() createFacilityDto: CreateFacilityDto): Promise<Facility> {
return this.facilitiesService.create(createFacilityDto);
@@ -30,6 +34,7 @@ export class FacilitiesController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.facilities.manage)
@ApiOperation({ summary: 'Update a facility' })
async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
return this.facilitiesService.update(id, updateFacilityDto);
@@ -37,6 +42,7 @@ export class FacilitiesController {
@Delete(':id')
@HttpCode(204)
@BookingStaff(FREIGHT_PERMS.facilities.manage)
@ApiOperation({ summary: 'Delete a facility (soft delete)' })
async remove(@Param('id') id: string): Promise<void> {
return this.facilitiesService.remove(id);

View File

@@ -13,7 +13,8 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
@@ -53,14 +54,14 @@ export class FileUploadSettingsController {
}
@Post()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create a new file upload setting" })
create(@Body() dto: CreateFileUploadSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a file upload setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -70,7 +71,7 @@ export class FileUploadSettingsController {
}
@Delete(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a file upload setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -80,7 +81,7 @@ export class FileUploadSettingsController {
/* ------------------------- field routes ------------------------- */
@Put(":id/fields")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the full field list for a setting" })
replaceFields(
@Param("id", ParseUUIDPipe) id: string,
@@ -90,7 +91,7 @@ export class FileUploadSettingsController {
}
@Post(":id/fields")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Append a single field to a setting" })
addField(
@Param("id", ParseUUIDPipe) id: string,
@@ -100,7 +101,7 @@ export class FileUploadSettingsController {
}
@Patch("fields/:fieldId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a single field" })
updateField(
@Param("fieldId", ParseUUIDPipe) fieldId: string,
@@ -110,7 +111,7 @@ export class FileUploadSettingsController {
}
@Delete("fields/:fieldId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a single field" })
@HttpCode(HttpStatus.NO_CONTENT)
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {

View File

@@ -16,6 +16,7 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { MixedAudience } from "../../common/booking-guards";
import { FilesService } from "./files.service";
@ApiTags("files")
@@ -25,6 +26,7 @@ export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
@MixedAudience([])
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer

View File

@@ -28,7 +28,9 @@ import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@BookingStaff(FREIGHT_PERMS.firstMile.view)
// No class-level key: Nest stacks class and method guards, so a class-level
// `view` would AND with every action key below and lock out staff granted only
// an action (e.g. assign_vehicles). Each route carries its own key instead.
export class FirstMileController {
constructor(
private readonly firstMileService: FirstMileService,
@@ -36,6 +38,7 @@ export class FirstMileController {
) { }
@Get()
@BookingStaff(FREIGHT_PERMS.firstMile.view)
@ApiOperation({ summary: 'List first-mile legs' })
findAll(
@Query('status') status?: string,
@@ -58,6 +61,7 @@ export class FirstMileController {
}
@Get(':id')
@BookingStaff(FREIGHT_PERMS.firstMile.view)
@ApiOperation({ summary: 'Get a first-mile leg by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.findById(id);

View File

@@ -6,7 +6,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { LastMileRequestStatus } from '@edr/types';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto';
@@ -61,12 +61,14 @@ export class LastMileRequestsController {
// Customer-facing like :id/submit — the service ownership-checks against the
// resolved company; staff may also open it (read-only view).
@Get(':id/contract/view')
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' })
contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
return this.contractService.getContractView(id, user?.id ?? null);
}
@Get(':id/contract/document')
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Download the LM contract PDF (LM_<CustomerName>.pdf)' })
async contractDocument(
@Param('id', ParseUUIDPipe) id: string,
@@ -79,6 +81,7 @@ export class LastMileRequestsController {
}
@Post(':id/contract/sign')
@PortalCustomer()
@ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@@ -99,6 +102,7 @@ export class LastMileRequestsController {
// TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service
// still cross-checks the request's booking against the resolved company.
@Post(':id/submit')
@PortalCustomer()
@ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" })
submit(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -34,7 +34,9 @@ import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
// No class-level key: Nest stacks class and method guards, so a class-level
// `view` would AND with every action key below and lock out staff granted only
// an action (e.g. assign_vehicles). Each route carries its own key instead.
export class LastMileController {
constructor(
private readonly lastMileService: LastMileService,
@@ -42,6 +44,7 @@ export class LastMileController {
) {}
@Get()
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: 'List last-mile legs' })
findAll(
@Query('status') status?: string,
@@ -64,18 +67,21 @@ export class LastMileController {
}
@Get(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: 'Get a last-mile leg by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.findById(id);
}
@Get('booking/:bookingId/arrival-trucks')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" })
arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.lastMileService.arrivalTrucksForBooking(bookingId);
}
@Get('booking/:bookingId/remaining-tons')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total departed trucks)' })
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.lastMileService.remainingTonsForBooking(bookingId);

View File

@@ -460,8 +460,21 @@ export class LastMileService {
@OnEvent("last_mile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
// Invoice paid → the delivery is complete. Route through update() so it
// also frees the trucks + records history (same as "Mark Delivered").
if (payload.type === 'LAST_MILE_ADVANCE') {
// Advance paid → the leg becomes dispatchable, not delivered.
await this.update(payload.sourceId, {
status: 'READY_TO_TRANSIT',
advancedPayment: payload.totalAmount,
} as unknown as UpdateLastMileDto);
this.logger.log(
`Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`,
);
return;
}
if (payload.type !== 'DELIVERY_FEE') return;
// Delivery-fee invoice paid → the delivery is complete. Route through
// update() so it also frees the trucks + records history (same as
// "Mark Delivered").
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
} catch (err) {

View File

@@ -1,11 +1,5 @@
import { CurrentUser } from "@edr/api-common";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import {
Body,
Controller,
Get,
Param,
@@ -62,22 +56,4 @@ export class NotificationInboxController {
return this.service.markAllRead(resolveAuthUserId(user));
}
// TODO: remove before merge — dev/verification helper only.
@Post("test")
@ApiOperation({
summary: "[dev] Send a test notification to the current user",
})
sendTest(
@CurrentUser() user: AuthUserPayload,
@Body()
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
) {
return this.service.sendTestToUser(resolveAuthUserId(user), body ?? {});
}
}

View File

@@ -1,11 +1,9 @@
import {
NotificationAudience,
NotificationChannels,
NotificationChannelsSent,
NotificationDto,
NotificationListResult,
NotificationPriority,
NotificationType,
NotifyInput,
} from "@edr/types";
import { Injectable, Logger } from "@nestjs/common";
@@ -106,31 +104,6 @@ export class NotificationInboxService {
return { updated, unreadCount };
}
/** [dev/verification only] Send a canned notification straight to one user. */
async sendTestToUser(
userId: string,
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
): Promise<NotificationDto> {
const entity = await this.repo.create({
recipientUserId: userId,
audience: body.audience ?? NotificationAudience.BACKOFFICE,
type: body.type ?? NotificationType.GENERIC,
title: body.title ?? "Test notification",
body: body.message ?? "This is a test in-app notification.",
priority: body.priority ?? NotificationPriority.NORMAL,
isRead: false,
});
const dto = this.toDto(entity);
this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId));
return dto;
}
private async deliverToUser(
userId: string,
input: NotifyInput,

View File

@@ -8,7 +8,8 @@ import {
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { OverviewQueryDto } from './dto/overview-query.dto';
import { OverviewResponseDto } from './dto/overview-response.dto';
import {
@@ -32,7 +33,7 @@ export class OverviewController {
) {}
@Get()
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
@ApiOkResponse({ type: OverviewResponseDto })
async getDashboard(
@@ -48,7 +49,7 @@ export class OverviewController {
}
@Get('bookings')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
@ApiOkResponse({ type: OverviewBookingsTabDto })
async getBookingsTab(
@@ -64,7 +65,7 @@ export class OverviewController {
}
@Get('contracts')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Contracts tab metrics and charts' })
@ApiOkResponse({ type: OverviewContractsTabDto })
async getContractsTab(
@@ -80,7 +81,7 @@ export class OverviewController {
}
@Get('billing')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Billing tab metrics and charts' })
@ApiOkResponse({ type: OverviewBillingTabDto })
async getBillingTab(
@@ -96,7 +97,7 @@ export class OverviewController {
}
@Get('operations')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Operations tab metrics and charts' })
@ApiOkResponse({ type: OverviewOperationsTabDto })
getOperationsTab(
@@ -106,7 +107,7 @@ export class OverviewController {
}
@Get('customers')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Customers tab metrics and charts' })
@ApiOkResponse({ type: OverviewCustomersTabDto })
async getCustomersTab(
@@ -122,7 +123,7 @@ export class OverviewController {
}
@Get('staff')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Staff tab metrics and charts' })
@ApiOkResponse({ type: OverviewStaffTabDto })
getStaffTab(@Query() query: OverviewQueryDto): Promise<OverviewStaffTabDto> {

View File

@@ -1,8 +1,8 @@
import {
IsEnum,
IsIn,
IsInt,
IsISO8601,
IsNumber,
IsOptional,
IsPositive,
IsString,
@@ -37,7 +37,9 @@ export class PaymentEventDto {
@ApiProperty() @IsString() referenceId!: string;
@ApiProperty() @IsString() merchantOrderId!: string;
@ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string;
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
// Major units, fractional (payment-api stores it as double precision) — an
// invoice of 12345.67 must not be rejected by an integer-only validator.
@ApiProperty() @IsNumber() @IsPositive() amountMinor!: number;
@ApiProperty() @IsString() currency!: string;
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;

View File

@@ -18,7 +18,7 @@ import {
import { Response } from "express";
import { CurrentUser, Public } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff, BookingView } from "../../common/booking-guards";
import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { PaymentService } from "./payment.service";
@@ -43,14 +43,14 @@ export class PaymentController {
}
@Get("summary")
@BookingView()
@BookingStaff(FREIGHT_PERMS.payments.view)
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
getSummary() {
return this.paymentService.getSummary();
}
@Get("all")
@BookingView()
@BookingStaff(FREIGHT_PERMS.payments.view)
@ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" })
@ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "status", required: false })
@@ -79,6 +79,7 @@ export class PaymentController {
}
@Get("intents/:bookingId")
@MixedAudience(FREIGHT_PERMS.payments.view)
@ApiOperation({ summary: "Get payment intent status for a booking" })
@ApiOkResponse({ type: IntentStatusDto })
getIntent(@Param("bookingId") bookingId: string) {
@@ -86,6 +87,7 @@ export class PaymentController {
}
@Post("redirect-success/:bookingId")
@PortalCustomer()
@ApiOperation({
summary:
"Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)",

View File

@@ -1,5 +1,7 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ProcurementService } from './procurement.service';
import {
CreateVendorDto,
@@ -11,11 +13,13 @@ import {
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
@BookingStaff(FREIGHT_PERMS.procurement.view)
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}
// ---- Vendors ----
@Post('vendors')
@BookingStaff(FREIGHT_PERMS.procurement.vendorManage)
@ApiOperation({ summary: 'Create a vendor' })
async createVendor(@Body() dto: CreateVendorDto) {
return this.procurementService.createVendor(dto);
@@ -28,12 +32,14 @@ export class ProcurementController {
}
@Patch('vendors/:id')
@BookingStaff(FREIGHT_PERMS.procurement.vendorManage)
@ApiOperation({ summary: 'Update a vendor' })
async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) {
return this.procurementService.updateVendor(id, dto);
}
@Delete('vendors/:id')
@BookingStaff(FREIGHT_PERMS.procurement.vendorManage)
@ApiOperation({ summary: 'Delete a vendor' })
async deleteVendor(@Param('id') id: string) {
return this.procurementService.deleteVendor(id);
@@ -41,6 +47,7 @@ export class ProcurementController {
// ---- Acquisitions ----
@Post('acquisitions')
@BookingStaff(FREIGHT_PERMS.procurement.acquisitionManage)
@ApiOperation({ summary: 'Create an asset acquisition' })
async createAcquisition(@Body() dto: CreateAcquisitionDto) {
return this.procurementService.createAcquisition(dto);
@@ -59,12 +66,14 @@ export class ProcurementController {
}
@Patch('acquisitions/:id')
@BookingStaff(FREIGHT_PERMS.procurement.acquisitionManage)
@ApiOperation({ summary: 'Update an asset acquisition' })
async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) {
return this.procurementService.updateAcquisition(id, dto);
}
@Delete('acquisitions/:id')
@BookingStaff(FREIGHT_PERMS.procurement.acquisitionManage)
@ApiOperation({ summary: 'Delete an asset acquisition' })
async deleteAcquisition(@Param('id') id: string) {
return this.procurementService.deleteAcquisition(id);
@@ -72,6 +81,7 @@ export class ProcurementController {
// ---- Disposals ----
@Post('disposals')
@BookingStaff(FREIGHT_PERMS.procurement.disposalManage)
@ApiOperation({ summary: 'Create an asset disposal' })
async createDisposal(@Body() dto: CreateDisposalDto) {
return this.procurementService.createDisposal(dto);
@@ -84,6 +94,7 @@ export class ProcurementController {
}
@Delete('disposals/:id')
@BookingStaff(FREIGHT_PERMS.procurement.disposalManage)
@ApiOperation({ summary: 'Delete an asset disposal' })
async deleteDisposal(@Param('id') id: string) {
return this.procurementService.deleteDisposal(id);

View File

@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swa
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { ReportQueryDto } from './dto/report-query.dto';
import { ReportResultDto } from './dto/report-result.dto';
@@ -19,7 +20,7 @@ export class ReportsController {
) {}
@Get(':key')
@BookingView()
@BookingStaff(FREIGHT_PERMS.reports.view)
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
@ApiOkResponse({ type: ReportResultDto })
async run(

View File

@@ -5,7 +5,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineApprove, RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -68,7 +68,7 @@ export class RatesController {
}
@Post(':id/approve')
@RuleEngineUpdate('rates')
@RuleEngineApprove('rates')
@ApiOperation({ summary: 'CEO approves a rate' })
approve(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -43,6 +43,12 @@ export class YardsRepository implements IYardsRepository {
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
const qb = this.repo
.createQueryBuilder('yard')
// createQueryBuilder does NOT auto-apply the soft-delete filter that
// repo.find()/findOne() get for free — without this, a renamed/replaced
// yard (e.g. an old "DMP" superseded by a new one) still shows up
// alongside the live one in every picker built off this endpoint, and a
// route picked against the dead yard id never matches any LIVE rate.
.where('yard.deleted_at IS NULL')
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
.addOrderBy('yard.label', 'ASC');

View File

@@ -7,7 +7,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PaginatedResponse, YardCountry } from '@edr/types';
import { Not } from 'typeorm';
import { IsNull, Not } from 'typeorm';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
@@ -344,11 +344,12 @@ export class RatesService {
/**
* Validate and normalise the last-mile band fields for a rate shape.
*
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — price =
* tons × km × rate, one row, no scope) and container (PER_KM — one row per
* container type per distance band, price = km × rate × quantity). Every
* other rate shape has its band fields cleared, mirroring how yard scope is
* cleared for non-route rates.
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row
* per distance band, price = tons × km × rate) and container (PER_KM — one
* row per container type per distance band, price = km × rate × quantity).
* A bandless bulk row (NULL minKm) is the legacy pre-band shape and still
* prices every distance. Every other rate shape has its band fields cleared,
* mirroring how yard scope is cleared for non-route rates.
*/
private resolveLastMileBand(input: {
appliesTo: Rate['appliesTo'];
@@ -366,7 +367,21 @@ export class RatesService {
'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.',
);
}
return { minKm: null, maxKm: null };
const minKm = input.minKm ?? null;
const maxKm = input.maxKm ?? null;
if (minKm === null) {
if (maxKm !== null) {
throw new BadRequestException(
'"To km" needs a "From km" — set the band start (0 for the first tier).',
);
}
// Legacy bandless bulk rate — prices every distance.
return { minKm: null, maxKm: null };
}
if (maxKm !== null && maxKm <= minKm) {
throw new BadRequestException('"To km" must be greater than "From km".');
}
return { minKm, maxKm };
}
if (rateUnit === 'PER_KM') {
@@ -393,14 +408,16 @@ export class RatesService {
}
/**
* Reject a container last-mile band that overlaps an existing band for the
* same container type. Bands are half-open [minKm, maxKm) with NULL maxKm =
* open-ended, so 030 and 30∞ tile cleanly. Checked across every
* non-superseded row (DRAFT included) — two drafts with colliding bands would
* only defer the conflict to approval.
* Reject a last-mile band that overlaps an existing band for the same scope —
* container bands collide per container type (PER_KM), bulk bands collide
* with each other (PER_TON_KM, no container scope). Bands are half-open
* [minKm, maxKm) with NULL maxKm = open-ended, so 030 and 30∞ tile
* cleanly. Checked across every non-superseded row (DRAFT included) — two
* drafts with colliding bands would only defer the conflict to approval.
*/
private async assertNoBandOverlap(input: {
containerTypeId: string;
rateUnit: 'PER_KM' | 'PER_TON_KM';
containerTypeId: string | null;
minKm: number;
maxKm: number | null;
ignoreId?: string;
@@ -408,8 +425,8 @@ export class RatesService {
const siblings = await this.repository.findAll({
where: {
rateType: 'LAST_MILE',
rateUnit: 'PER_KM',
containerTypeId: input.containerTypeId,
rateUnit: input.rateUnit,
containerTypeId: input.containerTypeId ?? IsNull(),
status: Not('SUPERSEDED'),
},
});
@@ -425,7 +442,7 @@ export class RatesService {
if (input.minKm < sibMax && sibMin < newMax) {
const sibLabel = `${sibMin}${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`;
throw new ConflictException(
`This distance band overlaps the existing ${sibLabel} band for this container type. Adjust the ranges so each distance falls in exactly one band.`,
`This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`,
);
}
}
@@ -538,8 +555,12 @@ export class RatesService {
minKm: dto.minKm,
maxKm: dto.maxKm,
});
if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) {
await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm });
if (
appliesTo === 'LAST_MILE' &&
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
minKm !== null
) {
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
}
await this.assertNoDuplicatePattern({
@@ -742,12 +763,12 @@ export class RatesService {
updates.maxKm = maxKm;
if (
appliesTo === 'LAST_MILE' &&
rateUnit === 'PER_KM' &&
updates.containerTypeId &&
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
minKm !== null
) {
await this.assertNoBandOverlap({
containerTypeId: updates.containerTypeId,
rateUnit,
containerTypeId: updates.containerTypeId ?? null,
minKm,
maxKm,
ignoreId: id,

View File

@@ -18,10 +18,12 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { MixedAudience } from "../../common/booking-guards";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { FilesService } from "../files/files.service";
import { SupportChatService } from "./support-chat.service";
@@ -48,6 +50,7 @@ export class SupportAttachmentController {
) {}
@Get(":fileId")
@MixedAudience(FREIGHT_PERMS.support.agentView)
@ApiOperation({
summary: "Download a support chat attachment",
description:

View File

@@ -17,10 +17,12 @@ import {
import { FilesInterceptor } from "@nestjs/platform-express";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
@@ -38,12 +40,14 @@ export class SupportChatAgentController {
constructor(private readonly service: SupportChatService) {}
@Get("conversations")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({ summary: "List all support threads (shared inbox)" })
list(@Query() query: ListConversationsQueryDto) {
return this.service.listForAgents(query);
}
@Post("conversations")
@BookingStaff(FREIGHT_PERMS.support.agentSend)
@ApiOperation({
summary: "Start chatting with a company (returns the thread if one exists)",
})
@@ -52,6 +56,7 @@ export class SupportChatAgentController {
}
@Get("conversations/:id/messages")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({
summary: "List messages in a thread (newest page first)",
description:
@@ -67,6 +72,7 @@ export class SupportChatAgentController {
}
@Post("conversations/:id/messages")
@BookingStaff(FREIGHT_PERMS.support.agentSend)
@UseInterceptors(
FilesInterceptor(
SUPPORT_ATTACHMENT_FIELD,
@@ -106,6 +112,7 @@ export class SupportChatAgentController {
}
@Post("conversations/:id/read")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({ summary: "Mark a thread read (agent side)" })
read(
@CurrentUser() user: AuthUserPayload,
@@ -115,6 +122,7 @@ export class SupportChatAgentController {
}
@Get("unread-count")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({ summary: "Count unread threads (agent side)" })
unread(@CurrentUser() user: AuthUserPayload) {
return this.service.unreadCount(

View File

@@ -23,6 +23,7 @@ import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
} from "./attachment-upload.options";
import { PortalCustomer } from "../../common/booking-guards";
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
import { SendMessageDto } from "./dto/send-message.dto";
import { SupportChatService } from "./support-chat.service";
@@ -34,6 +35,7 @@ import { SupportChatService } from "./support-chat.service";
*/
@ApiTags("support-chat")
@Controller("support")
@PortalCustomer()
export class SupportChatController {
constructor(private readonly service: SupportChatService) {}

View File

@@ -31,6 +31,19 @@ export function paymentDrainMs(): number {
);
}
/**
* ISO timestamp of the end of a pay window's drain tail, for client display
* (the "payment processing" countdown). Null in ⇒ null out.
*/
export function paymentDrainEndsAtIso(
deadline: Date | string | null | undefined,
): string | null {
if (deadline == null) return null;
const ms = new Date(deadline).getTime();
if (!Number.isFinite(ms)) return null;
return new Date(ms + paymentDrainMs()).toISOString();
}
/**
* A pay window AND its drain tail have closed.
*

View File

@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { paymentDrainEndsAtIso } from './booking-batch.constants';
/**
* Server → client push for booking-window state changes. Same handshake model
@@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection {
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt),
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);

View File

@@ -1,5 +1,6 @@
import {
DEFAULT_PAYMENT_DRAIN_MINUTES,
paymentDrainEndsAtIso,
paymentDrainMs,
payWindowLapsed,
} from "./booking-batch.constants";
@@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => {
expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN);
}
});
it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => {
expect(paymentDrainEndsAtIso(deadline)).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(null)).toBeNull();
expect(paymentDrainEndsAtIso(undefined)).toBeNull();
expect(paymentDrainEndsAtIso("not-a-date")).toBeNull();
});
});

View File

@@ -10,6 +10,9 @@ import {
import { CurrentUser } from "@edr/api-common";
import {
BookingDocReviewAlert,
BookingStaff,
MixedAudience,
PortalCustomer,
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingReschedule,
@@ -17,6 +20,7 @@ import {
TrainSchedulingUpdate,
TrainSchedulingView,
} from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
@@ -71,6 +75,7 @@ export class TrainSchedulingController {
) { }
@Get("my-booking-windows")
@PortalCustomer()
@ApiOperation({
summary:
"Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)",
@@ -85,6 +90,10 @@ export class TrainSchedulingController {
}
@Get("contracts/:contractId/booking-windows")
@MixedAudience([
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.contracts.createBooking,
])
@ApiOperation({
summary:
"Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL",
@@ -496,14 +505,14 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/finalize")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Finalize a draft train schedule" })
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.finalizeSchedule(id);
}
@Post("schedules/:id/dispatch")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Dispatch a scheduled train" })
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
@@ -824,7 +833,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/mark-paid")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.markPaid)
@ApiOperation({
summary: "Staff: mark a reserved booking paid and allocate it now",
})
@@ -834,7 +843,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/expire")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.expireBooking)
@ApiOperation({
summary: "Staff: expire a reservation and free its capacity",
})

View File

@@ -153,6 +153,7 @@ import {
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
paymentDrainEndsAtIso,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
import {
@@ -1546,23 +1547,10 @@ export class TrainSchedulingService {
}
: globalCfg;
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
// lead, so a custom lead is honoured rather than rejected by the global one.
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
// Short-notice trains are allowed: a departure inside the booking lead
// window is NOT rejected — the window just opens immediately (opensAt is
// clamped to `now` below) instead of waiting out a lead that has already
// passed. Only `updateScheduleDate` still enforces the lead floor.
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
@@ -1577,6 +1565,11 @@ export class TrainSchedulingService {
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
@@ -6948,6 +6941,7 @@ export class TrainSchedulingService {
windowClosesAt: r.window_closes_at,
docReviewEndsAt: r.doc_review_ends_at,
paymentPhaseEndsAt: r.payment_phase_ends_at,
paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at),
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,

View File

@@ -11,8 +11,9 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { StaffReference } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto';
import { UserTradeAccessService } from './user-trade-access.service';
@@ -24,6 +25,7 @@ export class UserTradeAccessController {
constructor(private readonly service: UserTradeAccessService) {}
@Get()
@BookingStaff(FREIGHT_PERMS.tradeAccess.view)
@ApiOperation({ summary: 'List every configured user trade-direction scope' })
list(@CurrentUser() user: TCurrentUser) {
this.assertAdmin(user);
@@ -41,6 +43,7 @@ export class UserTradeAccessController {
}
@Put(':userId')
@BookingStaff(FREIGHT_PERMS.tradeAccess.manage)
@ApiOperation({
summary: 'Set the trade directions a backoffice user may see',
})

View File

@@ -41,7 +41,9 @@ const toInt = (value?: string): number | undefined => {
*/
@ApiTags('wagon-transfer-requests')
@Controller('wagon-transfer-requests')
@WagonTransferView()
// No class-level key: Nest stacks class and method guards, so a class-level
// `transfer_view` would AND with every action key below and lock out the OCC
// staff granted only `transfer_fulfill`. Reads carry the view key themselves.
export class WagonTransferRequestsController {
constructor(private readonly service: WagonTransferRequestsService) {}
@@ -56,6 +58,7 @@ export class WagonTransferRequestsController {
}
@Get()
@WagonTransferView()
@ApiOperation({
summary:
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
@@ -84,6 +87,7 @@ export class WagonTransferRequestsController {
// matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe).
@Get('history')
@WagonTransferView()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiOperation({
@@ -129,6 +133,7 @@ export class WagonTransferRequestsController {
}
@Get(':id')
@WagonTransferView()
@ApiOperation({ summary: 'Get one transfer request' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);

View File

@@ -17,7 +17,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import {
BookingStaff,
FleetManage,
StaffReference,
FleetView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWagonDto } from './dto/create-wagon.dto';
@@ -44,7 +44,7 @@ export class WagonsController {
}
@Get()
@StaffReference()
@FleetView(FREIGHT_PERMS.wagons.view)
@ApiOperation({
summary: 'List wagons, paginated ({items, meta}) — 10 per page by default',
})
@@ -53,14 +53,14 @@ export class WagonsController {
}
@Get(':id')
@StaffReference()
@FleetView(FREIGHT_PERMS.wagons.view)
@ApiOperation({ summary: 'Get a wagon by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.findById(id);
}
@Get(':id/movements')
@StaffReference()
@FleetView(FREIGHT_PERMS.wagons.view)
@ApiOperation({
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
})

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -457,7 +457,7 @@ export class WarehouseInventoryController {
}
@Get(':id/handover-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
@@ -468,7 +468,7 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -484,14 +484,14 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handovers')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post('handovers/:handoverId/sign')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
signHandover(
@Param('handoverId', ParseUUIDPipe) handoverId: string,
@@ -507,14 +507,14 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/request-handover-signature')
@StaffReference()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/grn-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
@@ -525,7 +525,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/release-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
@@ -536,7 +536,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handover-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
async bookingHandoverDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -571,21 +571,21 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/container-items')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Get('bookings/:bookingId/container-weights')
@StaffReference()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingContainerWeights(bookingId);
}
@Get('bookings/:bookingId/location')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingLocation(bookingId);

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
@@ -43,7 +43,7 @@ export class WarehouseInvoiceController {
}
@Get('bookings/:id/warehouse-fee-invoices')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForBooking(id);
@@ -71,14 +71,14 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.findById(id);
}
@Get('warehouse-fee-invoices/:id/document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.document(id);
@@ -89,7 +89,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id/receipt')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.receipt(id);
@@ -114,7 +114,7 @@ export class WarehouseInvoiceController {
}
@Post('warehouse-fee-invoices/:id/pay-online')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.pay)
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);

View File

@@ -14,13 +14,19 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
// Baseline read: warehouse reference data is consumed by inventory/dashboard
// flows too, so any of the three view permissions grants reads. Writes stack
// their specific create/update permission per route on top.
// flows too, so any of the view permissions grants reads. Writes stack their
// specific create/update permission per route on top — which means every key
// used by a route below must also appear here, or the class guard denies
// before the route's own key is ever consulted (Nest ANDs the two).
@Controller('warehouses')
@BookingStaff([
FREIGHT_PERMS.warehouses.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseDashboard.view,
FREIGHT_PERMS.warehouses.create,
FREIGHT_PERMS.warehouses.update,
FREIGHT_PERMS.warehouseYards.view,
FREIGHT_PERMS.warehouseYards.create,
])
export class WarehousesController {
constructor(

View File

@@ -1,5 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { Permission } from '@tria-plc/iamapi-common';
import {
Permission,
PositionPermission,
PositionTypePermission,
RolePermission,
} from '@tria-plc/iamapi-common';
import { DataSource } from 'typeorm';
/** Renamed rule-engine resources: old key -> new key (same permission id). */
@@ -14,6 +19,22 @@ const PERMISSION_KEY_RENAMES: ReadonlyArray<{ from: string; to: string }> = [
},
];
/**
* Keys retired by the permission-system redesign (docs/permission-system/01):
* seeded but never enforced anywhere, and no matching feature exists. Grants
* referencing them are revoked before the permission row is deleted.
*/
const RETIRED_PERMISSION_KEYS: ReadonlyArray<string> = [
'edr_freight_app:payments:verify',
'edr_freight_app:payments:refund',
'edr_freight_app:invoices:create',
'edr_freight_app:invoices:cancel',
'edr_freight_app:fuel:approve',
'edr_freight_app:maintenance:complete',
'edr_freight_app:bookings:payment_pnr',
'edr_freight_app:bookings:payment_verify',
];
@Injectable()
export class FreightPermissionKeyMigrationSeeder {
private readonly logger = new Logger(FreightPermissionKeyMigrationSeeder.name);
@@ -44,5 +65,29 @@ export class FreightPermissionKeyMigrationSeeder {
await permissionRepository.update({ id: existing.id }, { key: to });
this.logger.log(`Renamed permission key ${from} -> ${to}`);
}
for (const key of RETIRED_PERMISSION_KEYS) {
const existing = await permissionRepository.findOne({
where: { key },
select: { id: true },
});
if (!existing) {
continue;
}
// Revoke every grant first, then drop the permission row itself.
const permissionId = existing.id as string;
await this.dataSource
.getRepository(RolePermission)
.delete({ permissionId });
await this.dataSource
.getRepository(PositionPermission)
.delete({ permissionId });
await this.dataSource
.getRepository(PositionTypePermission)
.delete({ permissionId });
await permissionRepository.delete({ id: permissionId });
this.logger.log(`Retired permission key ${key}`);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ import {
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import { useEffect } from "react";
import {
@@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
@@ -164,7 +166,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Reports",
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.bookings.view,
permission: FREIGHT_PERMS.reports.view,
},
{
label: "Customers",
@@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
@@ -196,19 +204,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
permission: FREIGHT_PERMS.payments.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.bookings.view,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "Support",
href: "/dashboard/support",
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.view,
permission: FREIGHT_PERMS.support.agentView,
},
...demoItems,
],
@@ -647,6 +655,8 @@ const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
// The ET hub's rows open the shipment clearance detail at this URL.
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
];
const isEtClearanceItem = (item: SidebarItem): boolean =>
@@ -842,26 +852,30 @@ const App = () => {
element={<Navigate to="/dashboard/overview" replace />}
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="reports" element={<ReportsHubPage />} />
<Route path="reports/:reportKey" element={<ReportPage />} />
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"
element={<AiBookingMockTestPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<AiBookingMockTestPage />
</RequirePermission>
}
/>
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><BookingRequestsPage /></RequirePermission>} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<RequirePermission permission={FREIGHT_PERMS.payments.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="support" element={<SupportInboxPage />} />
<Route path="support" element={<RequirePermission permission={FREIGHT_PERMS.support.agentView}><SupportInboxPage /></RequirePermission>} />
<Route
path="customers"
element={
@@ -881,7 +895,7 @@ const App = () => {
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoicesPage />
</RequirePermission>
}
@@ -889,19 +903,37 @@ const App = () => {
<Route
path="invoices/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoiceDetailPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
<Route
path="wagon-cancellations"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.wagonCancellationView}
>
<WagonCancellationsPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id"
element={<BookingRequestDetailPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingRequestDetailPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingContractPage />
</RequirePermission>
}
/>
{/* Legacy booking-based clearance URLs → the contract clearance hub. */}
<Route
@@ -1089,44 +1121,26 @@ const App = () => {
path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route
path="warehouse-inventory"
element={<WarehouseInventoryPage />}
/>
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="import-trucks" element={<ImportTrucksPage />} />
<Route
path="edr-last-mile-returns"
element={<EDRLastMileReturnsPage />}
/>
<Route path="container-returns" element={<ContainerReturnsPage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route
path="export-djibouti-unloading"
element={<ExportDjiboutiUnloadingQueuePage />}
/>
<Route
path="interchange-documents"
element={<InterchangeDocumentsPage />}
/>
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route
path="warehouse-fee-invoices"
element={<WarehouseInvoicesPage />}
/>
<Route
path="warehouse-dashboard"
element={<WarehouseDashboardPage />}
/>
<Route path="warehouses" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseListPage /></RequirePermission>} />
<Route path="warehouses/:id" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseDetailPage /></RequirePermission>} />
<Route path="warehouse-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><WarehouseInventoryPage /></RequirePermission>} />
<Route path="import-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportWarehouseFlowPage /></RequirePermission>} />
<Route path="export-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportWarehouseFlowPage /></RequirePermission>} />
<Route path="arrival-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ArrivalQueuePage /></RequirePermission>} />
<Route path="loading-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadingQueuePage /></RequirePermission>} />
<Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
<Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
<Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
<Route path="edr-last-mile-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><EDRLastMileReturnsPage /></RequirePermission>} />
<Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
<Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
<Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />
<Route path="export-djibouti-unloading" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportDjiboutiUnloadingQueuePage /></RequirePermission>} />
<Route path="interchange-documents" element={<RequirePermission permission={FREIGHT_PERMS.interchangeDocuments.view}><InterchangeDocumentsPage /></RequirePermission>} />
<Route path="inventory-inquiry" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><InventoryInquiryPage /></RequirePermission>} />
<Route path="warehouse-rules" element={<RequirePermission permission={[FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view]}><WarehouseRulesPage /></RequirePermission>} />
<Route path="warehouse-fee-invoices" element={<RequirePermission permission={FREIGHT_PERMS.warehouseFeeInvoices.view}><WarehouseInvoicesPage /></RequirePermission>} />
<Route path="warehouse-dashboard" element={<RequirePermission permission={FREIGHT_PERMS.warehouseDashboard.view}><WarehouseDashboardPage /></RequirePermission>} />
<Route
path="operations/train-scheduling"
@@ -1334,62 +1348,6 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="fuel-purchases"
element={
@@ -1470,108 +1428,6 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="train-builder"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainBuilderListPage />
</RequirePermission>
}
/>
<Route
path="train-builder/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainBuilderDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="wagon-transfers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
]}
>
<WagonTransfersPage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
{/* Legacy embedded user management routes */}
{/* <Route path="user-management" element={<UserManagementPage />} />

View File

@@ -23,7 +23,13 @@ interface AuthEmployeePosition {
permissions?: AuthPermission[];
/** Some IAM payloads nest the position record instead of flattening its key. */
position?: { id?: string; key?: string; name?: LocaleText };
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
positionType?: {
id?: string;
key?: string;
name?: LocaleText;
/** Grants held by the TYPE — where admin-created positions keep theirs. */
permissions?: AuthPermission[];
} | null;
}
interface AuthEmployeeRecord {

View File

@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
import { Hash, Package, Ship, Weight, Clock, TrainFront } from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -50,6 +50,21 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
},
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
];
// Allocated train facts — only once the booking rides a schedule.
if (booking.trainSchedule?.trainNumber || booking.trainSchedule?.reference) {
facts.splice(1, 0, {
icon: TrainFront,
label: "Allocated Train",
value: [
booking.trainSchedule.trainNumber
? `Train ${booking.trainSchedule.trainNumber}`
: null,
booking.trainSchedule.reference ?? null,
]
.filter(Boolean)
.join(" · "),
});
}
return (
<SectionCard icon={Hash} title="Booking Details" accent="cyan">

View File

@@ -144,4 +144,10 @@ export interface BookingDetailView {
bookingContainers?: BookingContainerView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
/** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;
}

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { AlertTriangle, Send } from "lucide-react";
import { AlertTriangle, Pencil, Send } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
@@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps {
scheduledDate?: string | null;
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
canResubmit: boolean;
/** Completion-form route for editing the cargo before resubmitting —
* rendered only for resubmit-capable users when provided. */
editHref?: string;
onResubmitted?: () => void;
}
@@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({
note,
scheduledDate,
canResubmit,
editHref,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
@@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({
>
Resubmit to Operations
</Button>
{editHref ? (
<Button
component={Link}
to={editHref}
variant="default"
radius="md"
size="sm"
leftSection={<Pencil size={15} />}
>
Edit cargo & resubmit
</Button>
) : null}
</Group>
) : null}
</Stack>

View File

@@ -68,6 +68,8 @@ export function ClearanceOpsTabs({
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
// Risk assignment + incident reporting hit bookings:operations endpoints.
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) {
@@ -98,12 +100,12 @@ export function ClearanceOpsTabs({
Document exchange
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
{showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
</Tabs.Tab>
) : null}
{showOpsTabs && bookingId ? (
{showOpsTabs && canOps && bookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
@@ -129,7 +131,7 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
{showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
@@ -137,7 +139,7 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showOpsTabs && bookingId ? (
{showOpsTabs && canOps && bookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm">

View File

@@ -41,6 +41,8 @@ import {
fetchViewableFile,
} from "@/services/files.service";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useFileViewer } from "@/hooks/useFileViewer";
export interface ContractClearanceReviewSectionProps {
@@ -123,6 +125,21 @@ export function ContractClearanceReviewSection({
} | null>(null);
const { view, viewer } = useFileViewer();
// Mirror the API guards: Path A (self-clearance) actions need
// ops_clearance_review; Path B (customs) review needs clearance_review or
// the ET phased key. Without the matching key every action would 403 — show
// the audit view instead of dead buttons.
const { user } = useAuth();
const canReviewHere = selfClear
? hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview)
: hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
// Finalize has its own API key on the customs path (contracts:finalize_clearance).
const canFinalizeHere = selfClear
? hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview)
: hasPermission(user, FREIGHT_PERMS.contracts.finalizeClearance);
readOnly = readOnly || !canReviewHere;
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
const { data: clearance, isLoading } = useQuery({
@@ -484,7 +501,7 @@ export function ContractClearanceReviewSection({
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
disabled={!clearance.allApproved || !canFinalizeHere}
loading={finalizeClearance.isPending}
onClick={() =>
finalizeClearance.mutate(undefined, {

View File

@@ -225,7 +225,13 @@ export function ExportClearanceStepper({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Description is the only part of a passed step that stays visible,
// so it carries the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -961,7 +961,7 @@ export default function GlCreateBookingForm() {
// modal falls back to the contract unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto),
contractsService.validateShipment(id ?? "", dto, completeBookingId),
});
const validation = validateShipmentMutation.data ?? null;

View File

@@ -39,6 +39,8 @@ interface WindowRow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
/** End of the payment drain tail — pending payments may settle until then. */
paymentDrainEndsAt?: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -94,16 +96,29 @@ const COUNTDOWN_TEXT: Partial<
PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
OPEN: { label: "Closes in", expiredText: "Review starting…" },
DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
PAYMENT: { label: "Payment ends in", expiredText: "Finalizing…" },
};
function phaseCountdown(
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
function phaseCountdown(w: WindowRow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Processing payments — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
@@ -225,6 +240,8 @@ function WindowCard({ w }: { w: WindowRow }) {
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
size="xs"
/>
</Box>

View File

@@ -357,7 +357,14 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Once the flow moves past this step its content collapses — the
// description is the only slot that stays visible, so it carries
// the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -234,7 +234,7 @@ const RuleEngineCardGrid = ({
{col.header}:
</Text>
<div style={{ textAlign: "right", flex: 1 }}>
{formatCell(displayValue, col.format)}
{formatCell(displayValue, col.format, record)}
</div>
</Group>
);

View File

@@ -127,6 +127,8 @@ const buildInitialValues = (
} else {
values[field.name] = raw;
}
} else if (field.defaultValue !== undefined) {
values[field.name] = field.defaultValue;
} else if (field.type === "boolean") {
values[field.name] = false;
} else if (field.type === "number") {

View File

@@ -17,7 +17,13 @@ const extractLabel = (value: unknown): string | null => {
);
};
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
export const formatCell = (
value: unknown,
format?: ColumnFormat,
// The row the cell came from — currency amounts read their code off it so a
// last-mile rate priced in birr does not render as USD.
row?: Record<string, unknown>,
): ReactNode => {
if (value === null || value === undefined || value === "") {
return <Text size="sm" c="dimmed"></Text>;
}
@@ -109,9 +115,10 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
if (format === "currency") {
const num = Number(value);
const code = typeof row?.currency === "string" ? row.currency : "USD";
return (
<Text size="sm" fw={500}>
{Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`}
{Number.isNaN(num) ? String(value) : `${code} ${num.toLocaleString()}`}
</Text>
);
}

View File

@@ -19,6 +19,8 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
interface ScheduleBatchPanelProps {
schedule: TrainScheduleDetail;
@@ -31,6 +33,8 @@ const windowColor: Record<string, string> = {
};
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
const { user } = useAuth();
const canMarkPaid = hasPermission(user, FREIGHT_PERMS.trainScheduling.markPaid);
const { toast } = useToast();
const actions = {
runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()),
@@ -179,7 +183,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Table.Td>
{!locked && (
<Group gap={6} justify="flex-end" wrap="nowrap">
{b.status !== "PAID" && (
{canMarkPaid && b.status !== "PAID" && (
<Button
size="compact-xs"
variant="light"

View File

@@ -30,6 +30,7 @@ interface WindowRow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
paymentDrainEndsAt?: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -55,6 +56,7 @@ function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent)
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
paymentDrainEndsAt: event.paymentDrainEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}

View File

@@ -6,7 +6,32 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:overview:view",
},
support: {
view: "edr_freight_app:support:view",
agentView: "edr_freight_app:support:agent_view",
agentSend: "edr_freight_app:support:agent_send",
},
reports: {
view: "edr_freight_app:reports:view",
},
procurement: {
view: "edr_freight_app:procurement:view",
vendorManage: "edr_freight_app:procurement:vendor_manage",
acquisitionManage: "edr_freight_app:procurement:acquisition_manage",
disposalManage: "edr_freight_app:procurement:disposal_manage",
},
compliance: {
view: "edr_freight_app:compliance:view",
manage: "edr_freight_app:compliance:manage",
},
facilities: {
view: "edr_freight_app:facilities:view",
manage: "edr_freight_app:facilities:manage",
},
tradeAccess: {
view: "edr_freight_app:trade_access:view",
manage: "edr_freight_app:trade_access:manage",
},
staffUsers: {
view: "edr_freight_app:staff:users:view",
},
bookings: {
view: "edr_freight_app:bookings:view",
@@ -27,6 +52,11 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
governmentExpedite: "edr_freight_app:bookings:government_expedite",
wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view",
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -61,6 +91,9 @@ export const FREIGHT_PERMS = {
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
@@ -69,6 +102,9 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
},
fleet: {
view: "edr_freight_app:fleet:view",
@@ -88,13 +124,9 @@ export const FREIGHT_PERMS = {
},
payments: {
view: "edr_freight_app:payments:view",
verify: "edr_freight_app:payments:verify",
refund: "edr_freight_app:payments:refund",
},
invoices: {
view: "edr_freight_app:invoices:view",
create: "edr_freight_app:invoices:create",
cancel: "edr_freight_app:invoices:cancel",
export: "edr_freight_app:invoices:export",
},
firstMile: {
@@ -191,14 +223,12 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:fuel:create",
update: "edr_freight_app:fuel:update",
delete: "edr_freight_app:fuel:delete",
approve: "edr_freight_app:fuel:approve",
},
maintenance: {
view: "edr_freight_app:maintenance:view",
create: "edr_freight_app:maintenance:create",
update: "edr_freight_app:maintenance:update",
delete: "edr_freight_app:maintenance:delete",
complete: "edr_freight_app:maintenance:complete",
},
fleetReports: {
view: "edr_freight_app:fleet_reports:view",
@@ -278,6 +308,14 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
},
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
},
},
audit: {
view: "edr_freight_app:audit:view",
@@ -348,6 +386,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
// Positions created through the admin UI keep their grants on the
// position TYPE, not the position — miss these and such staff resolve to
// zero permissions and every gated route rejects them. `/api/me` folds
// them into the position's permission list, but older payloads may still
// carry them separately.
for (const p of pos.positionType?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
@@ -563,7 +609,9 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
}
/** Any train-scheduling write action (create / update / cancel / reschedule). */
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
export function canManageScheduling(
user: AuthUser | null | undefined,
): boolean {
return (
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||

View File

@@ -0,0 +1,420 @@
import {
Anchor,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Search, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { toDayString } from "@/hooks/useListControls";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: { id: string; reference: string; company?: { name: string } };
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
}
interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
const STATUS_CHIP: Record<
WagonCancellationStatus,
{ label: string; color: string }
> = {
FEE_PENDING: { label: "Fee pending", color: "yellow" },
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
REBOOKED: { label: "Rebooked", color: "indigo" },
WITHDRAWN: { label: "Withdrawn", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
const STATUS_FILTER_OPTIONS = (
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
).map((s) => ({ value: s, label: STATUS_CHIP[s].label }));
function StatusChip({ status }: { status: WagonCancellationStatus }) {
const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" };
return (
<Badge
color={chip.color}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
style={{ fontSize: "0.7rem", letterSpacing: "0.05em" }}
>
{chip.label}
</Badge>
);
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
/**
* Staff view of partial wagon cancellations: every slice of capacity a
* customer gave back, its cancellation fee, and where the credit went
* (rebooked, still available, expired, or the request was voided).
*/
export default function WagonCancellationsPage() {
const { user } = useAuth();
const canVoid = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(status ? { statuses: status } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(from ? { from: toDayString(from) } : {}),
...(to ? { to: toDayString(to) } : {}),
}),
[pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to],
);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["bookings", "wagon-cancellations", filter],
queryFn: async () => {
const res = await api.get<WagonCancellationListResponse>(
"/bookings/wagon-cancellations/history",
{ params: filter },
);
return res.data;
},
});
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const withdraw = useMutation({
mutationFn: (id: string) =>
api.post(`/bookings/wagon-cancellations/${id}/withdraw`),
});
const columns: ColumnDef<WagonCancellation>[] = [
{
id: "requested",
header: () => <span>Requested</span>,
cell: ({ row }) => (
<Text size="xs" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
{
id: "booking",
header: () => <span>Booking</span>,
cell: ({ row }) => (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${row.original.bookingId}`}
size="sm"
fw={600}
>
{row.original.booking?.reference ?? row.original.bookingId}
</Anchor>
),
},
{
id: "company",
header: () => <span>Company</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
),
},
{
id: "wagons",
header: () => <span>Wagons</span>,
cell: ({ row }) => <Text size="sm">{row.original.wagonsCancelled}</Text>,
},
{
id: "fee",
header: () => <span>Fee</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "credit",
header: () => <span>Credit</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "status",
header: () => <span>Status</span>,
cell: ({ row }) => <StatusChip status={row.original.status} />,
},
{
id: "rebookedAs",
header: () => <span>Rebooked as</span>,
cell: ({ row }) => {
const r = row.original;
if (!r.rebookedBookingId) return <Text size="sm"></Text>;
return (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${r.rebookedBookingId}`}
size="sm"
>
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
</Anchor>
);
},
},
{
id: "actions",
header: () => <span />,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "FEE_PENDING" || !canVoid) return null;
return (
<Group justify="flex-end" wrap="nowrap">
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
</Group>
);
},
},
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Wagon cancellations"
subtitle="Partial wagon cancellations — fees charged, credits held, and where each credit was rebooked"
breadcrumbs={[
{ label: "Bookings", href: "/dashboard/booking-requests" },
{ label: "Wagon cancellations" },
]}
/>
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search booking ref or company…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetPage();
}}
w={260}
radius="md"
/>
<Select
placeholder="Status"
data={STATUS_FILTER_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
w={190}
radius="md"
/>
<DateInput
placeholder="From"
value={from}
onChange={(v) => {
setFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={to ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="To"
value={to}
onChange={(v) => {
setTo(v ? new Date(v) : null);
resetPage();
}}
minDate={from ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<Button
variant="subtle"
radius="md"
onClick={() => {
setStatus(null);
setSearch("");
setFrom(null);
setTo(null);
resetPage();
}}
>
Clear
</Button>
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Stack>
<Modal
opened={Boolean(voiding)}
onClose={() => setVoiding(null)}
radius="md"
title="Void this cancellation?"
>
{!voiding ? null : (
<Stack gap="sm">
<Text size="sm">
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
{voiding.wagonsCancelled} wagon(s) · fee{" "}
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
</Text>
<Text size="sm" c="dimmed">
The pending fee is dropped and the wagons stay on the booking.
Voiding can't be undone.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setVoiding(null)}
>
Keep it
</Button>
<Button
color="red"
radius="md"
leftSection={<XCircle size={15} />}
loading={withdraw.isPending}
onClick={async () => {
try {
await withdraw.mutateAsync(voiding.id);
toast.success("Cancellation voided");
setVoiding(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Void
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -161,10 +161,10 @@ export default function ClearanceDocumentsPage() {
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
<p className="font-medium text-foreground">
{customer}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{b.reference}
</p>
@@ -182,7 +182,7 @@ export default function ClearanceDocumentsPage() {
<ContractReferenceLink
contractId={b.contractId}
contractReference={b.contractReference}
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
className="block text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
/>
) : (
<Text size="sm"></Text>
@@ -409,7 +409,7 @@ export default function ClearanceDocumentsPage() {
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter}
/>
</Box>

View File

@@ -311,6 +311,7 @@ export default function ContractClearanceDetailPage() {
note={clearance.linkedBookingReviewNote}
scheduledDate={clearance.linkedBookingScheduledDate}
canResubmit={canResubmitBooking}
editHref={`/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`}
onResubmitted={() => {
void refetch();
void refetchContract();

View File

@@ -393,10 +393,10 @@ function ShipmentBookingsTable({
<PackageCheck className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
<p className="font-medium text-foreground">
{row.original.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel}
</p>
@@ -413,7 +413,7 @@ function ShipmentBookingsTable({
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={150}>
<Text size="sm" fw={500}>
{r.contractReference ?? "—"}
</Text>
</Group>
@@ -431,13 +431,9 @@ function ShipmentBookingsTable({
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Text size="sm" className="truncate">
{row.original.originLabel}
</Text>
<Text size="sm">{row.original.originLabel}</Text>
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" className="truncate">
{row.original.destinationLabel}
</Text>
<Text size="sm">{row.original.destinationLabel}</Text>
</Group>
),
},
@@ -610,7 +606,7 @@ function ShipmentBookingsTable({
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent"
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
/>
</Box>
);

View File

@@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{/* A contract resting in APPROVED means the automatic PDF generation on
final approval failed — on success it moves straight to
CONTRACT_READY. Offer the manual retry. */}
{contract.status === "APPROVED" ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Contract document was not generated"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
All approvals are complete, but generating the contract PDF
failed. Retry the generation below.
</Text>
<Button
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Regenerate contract
</Button>
</Stack>
</Alert>
) : null}
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"

Some files were not shown because too many files have changed in this diff Show More