This commit is contained in:
Marshal
2026-08-07 09:04:11 +00:00
72 changed files with 3224 additions and 1124 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
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";
@@ -220,7 +220,6 @@ if (!process.env.APPLICATION_NAME) {
HealthModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
PaymentModule,
//New Modules
@@ -240,6 +239,7 @@ if (!process.env.APPLICATION_NAME) {
ComplianceModule,
IncidentsModule,
ProcurementModule,
FacilitiesModule,
GpsTrackingModule,
FirstMileModule,
LastMileModule,

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);

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -15,15 +15,15 @@ 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,
MixedAudience,
PortalCustomer,
WagonCancellationView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -167,6 +167,7 @@ export class BookingsController {
) {}
@Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@@ -207,6 +208,7 @@ export class BookingsController {
}
@Patch(":id")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -223,6 +225,7 @@ export class BookingsController {
}
@Get()
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@@ -298,6 +301,7 @@ export class BookingsController {
}
@Get("my")
@PortalCustomer()
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
@@ -328,6 +332,7 @@ export class BookingsController {
}
@Get("reference-data")
@MixedAudience([])
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
@@ -335,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,
@@ -352,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,
@@ -373,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)',
@@ -395,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). ' +
@@ -420,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)',
})
@@ -447,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,
@@ -462,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).',
@@ -488,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)',
@@ -652,6 +665,11 @@ export class BookingsController {
}
@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,
@@ -665,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,
@@ -679,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,
@@ -693,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,
@@ -708,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,
@@ -722,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,
@@ -735,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,
@@ -749,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)',
})
@@ -766,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,
@@ -779,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',
@@ -796,6 +823,7 @@ export class BookingsController {
}
@Get(':id/tracking')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: "Shipment tracking timeline for a booking",
description:
@@ -818,6 +846,7 @@ export class BookingsController {
}
@Delete(":id")
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -825,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)" })
@@ -837,6 +867,7 @@ export class BookingsController {
}
@Post(":id/generate-price")
@MixedAudience([])
@ApiOperation({
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
@@ -848,6 +879,7 @@ export class BookingsController {
}
@Post(":id/submit")
@MixedAudience([])
@ApiOperation({
summary: "Customer submit booking",
description:
@@ -859,6 +891,7 @@ export class BookingsController {
}
@Post(":id/confirm-submit")
@MixedAudience([])
@ApiOperation({
summary: "Confirm submit after price change",
description:
@@ -870,6 +903,7 @@ export class BookingsController {
}
@Post(":id/reject")
@PortalCustomer()
@ApiOperation({
summary: "Customer reject price estimate",
description:
@@ -900,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)",
@@ -909,6 +944,7 @@ export class BookingsController {
}
@Post(":id/clearance/documents")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -925,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 " +
@@ -944,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 " +
@@ -1145,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',
@@ -1155,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)',
@@ -1181,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' })
@@ -1332,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",
})
@@ -1356,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(
@@ -1367,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,
@@ -1382,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,
@@ -1391,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,
@@ -1412,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)",
})
@@ -1504,6 +1553,7 @@ export class BookingsController {
}
@Post(":id/cancel-hold")
@PortalCustomer()
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
@@ -1518,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';
@@ -92,7 +93,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
SignaturesModule,
registerExchangeModule(),
],
controllers: [BookingsController],
controllers: [BookingsController, BookingAllocationController],
providers: [
BookingsService,
BookingsRepository,

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

@@ -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

@@ -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).',
@@ -1132,6 +1153,7 @@ export class ContractsController {
}
@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)',
@@ -1144,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);
@@ -1283,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({
@@ -1310,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',
})
@@ -1324,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' })
@@ -1335,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,
@@ -1381,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' })
@@ -1406,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' })
@@ -1422,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -166,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",
@@ -204,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,
],
@@ -852,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={
@@ -891,7 +895,7 @@ const App = () => {
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoicesPage />
</RequirePermission>
}
@@ -899,12 +903,12 @@ 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={
@@ -917,11 +921,19 @@ const App = () => {
/>
<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
@@ -1109,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"
@@ -1354,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={
@@ -1490,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

@@ -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

@@ -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

@@ -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,7 @@ 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:
@@ -65,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",
@@ -73,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",
@@ -92,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: {
@@ -195,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",
@@ -282,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",
@@ -575,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

@@ -13,6 +13,8 @@ import {
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -60,6 +62,8 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
}
export default function InvoiceDetailPage() {
const { user } = useAuth();
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [downloading, setDownloading] = useState(false);
@@ -124,6 +128,7 @@ export default function InvoiceDetailPage() {
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
onClick={() => void downloadDocument()}
>

View File

@@ -51,6 +51,8 @@ import {
} from "@/features/support/useSupport";
import { useSupportSocket } from "@/features/support/useSupportSocket";
import { customersService } from "@/services/customers.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type ReadFilter = "ALL" | "UNREAD";
@@ -401,6 +403,8 @@ function ConversationThread({
fetchNextPage,
} = useMessages(conversation.id);
const send = useSendMessage(conversation.id);
const { user: agentUser } = useAuth();
const canSend = hasPermission(agentUser, FREIGHT_PERMS.support.agentSend);
const markRead = useMarkConversationRead();
const [draft, setDraft] = useState("");
const [dragging, setDragging] = useState(false);
@@ -656,7 +660,7 @@ function ConversationThread({
color="edr-green"
variant="filled"
loading={send.isPending}
disabled={!draft.trim() && attach.attachments.length === 0}
disabled={!canSend || (!draft.trim() && attach.attachments.length === 0)}
onClick={submit}
>
<Send size={18} />

View File

@@ -42,6 +42,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { KpiStrip, PageContainer } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
autoFillPlacements,
mergePlacementsWithSaved,
@@ -102,6 +104,7 @@ const parseError = (error: unknown, fallback: string) => {
};
export default function TrainScheduleV2DetailPage() {
const { user: authUser } = useAuth();
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [activeStep, setActiveStep] = useState(0);
@@ -394,7 +397,9 @@ export default function TrainScheduleV2DetailPage() {
: [];
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canDispatch = schedule.status === "SCHEDULED";
const canDispatch =
schedule.status === "SCHEDULED" &&
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can

View File

@@ -63,7 +63,7 @@ import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
@@ -111,7 +111,7 @@ export default function TrainScheduleV2ListPage() {
const { toast } = useToast();
const { user } = useAuth();
const canCreate = canCreateSchedule(user);
const canUpdate = canUpdateSchedule(user);
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
@@ -491,7 +491,7 @@ export default function TrainScheduleV2ListPage() {
{/* Start the run. Same transition as the detail page's
Dispatch button — that page also shows unassigned-wagon
and not-loaded warnings, so it stays the fuller surface. */}
{canUpdate && schedule.status === "SCHEDULED" ? (
{canDispatch && schedule.status === "SCHEDULED" ? (
<Menu.Item
leftSection={<Play size={15} />}
onClick={() => setDispatchTarget(schedule)}

View File

@@ -38,6 +38,8 @@ import {
import { openPdfBlob } from '@/components/warehouses/pdf';
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
import { extractErrorMessage } from '@/components/warehouses/options';
import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
@@ -171,6 +173,9 @@ export default function WarehouseInvoicesPage() {
}
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { user } = useAuth();
const mayRecordPayment = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.pay);
const canCancelInvoice = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.cancel);
const { toast } = useToast();
const navigate = useNavigate();
const { data: inv, isLoading } = useQuery(
@@ -430,9 +435,11 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
placeholder="Optional"
style={{ flex: 1 }}
/>
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
</Button>
{mayRecordPayment && (
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
</Button>
)}
</Group>
<Divider label="Record manual payment" labelPosition="left" />
@@ -456,9 +463,11 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
onChange={(e) => setDriverPhone(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
{mayRecordPayment && (
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
)}
</Group>
</>
)}
@@ -506,7 +515,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
Gate clearance & exit paper
</Button>
)}
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && canCancelInvoice && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice
</Button>

View File

@@ -72,8 +72,33 @@ export default function HelpPage() {
<DocShell
current="/help"
title="Help & Support"
subtitle="Get answers fast — browse the common topics, check the FAQ, or reach our team directly."
subtitle="Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly."
>
<section className="mb-12">
<h2 className="text-xl font-bold tracking-tight">
Portal walkthrough
</h2>
<p className="mt-2 leading-7 text-muted-foreground">
A guided tour of the portal registering your company, raising a
booking against a contract, and settling an invoice.
</p>
{/* preload="metadata" so the 28 MB file is not pulled on every visit;
the browser fetches it only once playback starts. */}
<video
controls
preload="metadata"
className="mt-6 w-full rounded-[32px] border border-border bg-black"
>
<source src="/assets/edr-portal-guide.webm" type="video/webm" />
Your browser cannot play this video. Download it at{" "}
<a href="/assets/edr-portal-guide.webm">
/assets/edr-portal-guide.webm
</a>
.
</video>
</section>
{/* Live chat is the fastest route, so lead with it. */}
<div className="rounded-[32px] border border-border bg-card p-8">
<div className="flex items-start gap-4">

View File

@@ -1,13 +1,6 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { ReportsModule } from '../reports/reports.module';
@Module({
// ReportsModule owns the blocked-seat revenue loss rule; the dashboard's roll-up
// reads it from there instead of keeping a second copy of the definition.
imports: [ReportsModule],
controllers: [DashboardController],
providers: [DashboardService],
})
@Module({ controllers: [DashboardController], providers: [DashboardService] })
export class DashboardModule {}

View File

@@ -1,31 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { BlockedSeatRevenueLossStat } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
import { ReportsService } from '../reports/reports.service';
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
periodDays: null,
lossByCurrency: [],
schedulesAffected: 0,
blockedSeatCount: 0,
topReasonCategory: null,
};
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
private reports: ReportsService,
) {}
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] =
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
await Promise.all([
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
@@ -52,9 +38,6 @@ export class DashboardService {
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
`,
// Joined into this same call on purpose: the dashboard's request count stays
// exactly where it was, and the card renders from the payload it already fetches.
this.getBlockedSeatRevenueLossStat(),
]);
const totalPackageTickets = await this.prisma.ticket.count({
@@ -75,43 +58,11 @@ export class DashboardService {
totalNormalTickets: totalTickets - totalPackageTickets,
totalPassengers,
blockedSeatsCount,
blockedSeatRevenueLoss,
revenueByCurrency: toMap(revenueRows),
packageRevenueByCurrency: toMap(packageRevenueRows),
};
}
/**
* Compact roll-up of the Blocked Seat Revenue Loss report over its full history.
*
* Reuses the report service rather than re-deriving the rule — there is exactly one
* definition of what a blocked seat costs. A failure here degrades to zeroes instead of
* taking the whole dashboard down with it.
*/
private async getBlockedSeatRevenueLossStat(): Promise<BlockedSeatRevenueLossStat> {
try {
// pageSize 1: only the summary is read, and paging does not change what it covers.
const report = await this.reports.getBlockedSeatsRevenueLoss({ page: 1, pageSize: 1 });
const { summary } = report;
return {
periodDays: null,
lossByCurrency: summary.lossByCurrency,
schedulesAffected: summary.schedulesAffected,
blockedSeatCount: summary.blockedSeatCount,
// topReasonCategories is already sorted by estimated loss, descending.
topReasonCategory: summary.topReasonCategories[0]?.reasonCategory ?? null,
};
} catch (err) {
this.logger.warn(
`Blocked-seat revenue loss roll-up unavailable — ${
err instanceof Error ? err.message : String(err)
}`,
);
return EMPTY_BLOCKED_SEAT_LOSS;
}
}
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([

View File

@@ -193,7 +193,10 @@ function supersedes(candidate: CountedBlock, existing: CountedBlock): boolean {
return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime();
}
function isGlobalBlockInEffectAt(block: LossBlock, departureAt: Date): boolean {
export function isGlobalBlockInEffectAt(
block: Pick<LossBlock, 'blockedAt' | 'unblockAt'>,
departureAt: Date,
): boolean {
if (block.blockedAt.getTime() > departureAt.getTime()) return false;
if (block.unblockAt === null) return true;
return block.unblockAt.getTime() >= departureAt.getTime();
@@ -210,9 +213,10 @@ export function isPlaceholderSeat(seat: Pick<LossSeat, 'seatNumber'>): boolean {
* inflates the blocked-seat count. Match on a substring of type *or* name so both the
* documented convention and the data as it actually exists are covered.
*/
export function isDiningCoach(
coach: Pick<LossCoach, 'coachTypeType' | 'coachTypeName'>,
): boolean {
export function isDiningCoach(coach: {
coachTypeType?: string | null;
coachTypeName?: string | null;
}): boolean {
const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase();
return haystack.includes('dining');
}

View File

@@ -15,12 +15,16 @@ import {
} from "./reports.dto";
import {
assembleReport,
isDiningCoach,
isGlobalBlockInEffectAt,
isPlaceholderSeat,
LossCalculatorInput,
LossCoach,
LossFare,
LossSeat,
selectCountedBlocks,
soldKey,
TICKETING_BLOCK_REASON_PREFIX,
} from "./blocked-seats-loss.calculator";
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
@@ -528,7 +532,8 @@ export class ReportsService {
}
async getSeatStatusReport(scheduleId: string) {
// Confirmed/boarded seats — exclude dining coaches
// Confirmed/boarded seats. Dining coaches are dropped in JS below — `CoachType.type`
// holds display names in real data, so an exact match here would not catch them.
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
OR: [
@@ -536,7 +541,6 @@ export class ReportsService {
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
],
seat: { coach: { coachType: { type: { not: 'dining' } } } },
},
include: {
booking: {
@@ -565,12 +569,6 @@ export class ReportsService {
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
});
// Active seat holds for this schedule
const activeHolds = await this.prisma.seatHold.findMany({
where: { scheduleId },
orderBy: { createdAt: 'desc' },
});
// Expired holds (last 24h) — held but never converted to a booking
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const expiredHolds = await this.prisma.seatHold.findMany({
@@ -581,35 +579,89 @@ export class ReportsService {
orderBy: { expiresAt: 'desc' },
});
// Manually blocked seats — schedule-scoped blocks for this schedule OR global blocks (scheduleId null)
// Exclude MAINTENANCE and booking-system-created blocks
const blocks = await this.prisma.seatBlock.findMany({
where: {
OR: [
{ scheduleId },
{ scheduleId: null },
],
NOT: [
{ reason: { startsWith: 'MAINTENANCE:' } },
{ reason: { startsWith: 'Booked in tickets' } },
],
},
include: {
seat: {
select: {
seatNumber: true,
bedPosition: true,
coach: {
select: {
number: true,
coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } },
// Manually blocked seats. Counted the same way the blocked-seat revenue loss report
// counts them (see `selectCountedBlocks`), so the two reports never disagree:
// - a schedule-scoped block naming this schedule, or
// - a global block (scheduleId null) that was in effect at departure AND sits on a
// coach actually assigned to this train.
// A global block on a coach that never joined this consist is not a blocked seat here.
// Excluded: dining coaches, placeholder seats, ticket-issuance bookkeeping blocks, and
// MAINTENANCE (a seat out of service, not one withheld by hand).
const [schedule, assignments, blockRows] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { departureAt: true },
}),
this.prisma.coachAssignment.findMany({
where: { scheduleId },
select: { coachId: true },
}),
this.prisma.seatBlock.findMany({
where: {
OR: [
{ scheduleId },
{ scheduleId: null },
],
NOT: [
{ reason: { startsWith: 'MAINTENANCE:' } },
{ reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } },
],
},
include: {
seat: {
select: {
id: true,
coachId: true,
seatNumber: true,
bedPosition: true,
coach: {
select: {
number: true,
coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } },
},
},
},
},
},
},
orderBy: { blockedAt: 'desc' },
});
orderBy: { blockedAt: 'desc' },
}),
]);
const assignedCoachIds = new Set(assignments.map((a) => a.coachId));
const departureAt = schedule?.departureAt ?? null;
// One counted block per seat: a schedule-scoped block beats a global one, and between
// two of the same kind the most recent wins — the rows arrive newest-first, so the
// first of a kind seen for a seat is already the most recent.
const countedBySeat = new Map<string, (typeof blockRows)[number]>();
for (const block of blockRows) {
const seat = block.seat;
if (!seat || isPlaceholderSeat(seat)) continue;
const coachType = seat.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
if (block.scheduleId === null) {
if (!assignedCoachIds.has(seat.coachId)) continue;
if (!departureAt || !isGlobalBlockInEffectAt(block, departureAt)) continue;
}
const existing = countedBySeat.get(seat.id);
if (!existing || (existing.scheduleId === null && block.scheduleId !== null)) {
countedBySeat.set(seat.id, block);
}
}
const blocks = [...countedBySeat.values()].sort(
(a, b) => b.blockedAt.getTime() - a.blockedAt.getTime(),
);
const resolveSeatClass = (seat: any): string | null => {
const classes = seat?.coach?.coachType?.seatClasses ?? [];
@@ -619,10 +671,18 @@ export class ReportsService {
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
};
const paidSeats = bookingSeats.filter(bs =>
const passengerSeats = bookingSeats.filter(
bs =>
!isDiningCoach({
coachTypeType: bs.seat?.coach?.coachType?.type ?? null,
coachTypeName: bs.seat?.coach?.coachType?.name ?? null,
}),
);
const paidSeats = passengerSeats.filter(bs =>
bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED'
);
const unpaidSeats = bookingSeats.filter(bs =>
const unpaidSeats = passengerSeats.filter(bs =>
bs.booking.status === 'PENDING_PAYMENT'
);
@@ -645,7 +705,7 @@ export class ReportsService {
paidCount: paidSeats.length,
unpaidCount: unpaidSeats.length,
expiredHoldCount: expiredHolds.length,
blockedCount: blocks.filter(b => b.seat?.coach?.coachType?.type !== 'dining').length,
blockedCount: blocks.length,
},
paidSeats: paidSeats.map(mapSeat),
unpaidSeats: unpaidSeats.map(mapSeat),
@@ -655,18 +715,16 @@ export class ReportsService {
expiresAt: h.expiresAt,
createdAt: h.createdAt,
})),
blockedSeats: blocks
.filter(b => b.seat?.coach?.coachType?.type !== 'dining')
.map(b => ({
id: b.id,
coachNumber: b.seat?.coach?.number ?? null,
seatNumber: b.seat?.seatNumber ?? null,
seatClassName: resolveSeatClass(b.seat),
reason: b.reason,
blockedBy: b.blockedBy,
blockedAt: b.blockedAt,
unblockAt: b.unblockAt,
})),
blockedSeats: blocks.map(b => ({
id: b.id,
coachNumber: b.seat?.coach?.number ?? null,
seatNumber: b.seat?.seatNumber ?? null,
seatClassName: resolveSeatClass(b.seat),
reason: b.reason,
blockedBy: b.blockedBy,
blockedAt: b.blockedAt,
unblockAt: b.unblockAt,
})),
};
}

View File

@@ -0,0 +1,261 @@
import { ReportsService } from './reports.service';
/**
* Covers the blocked-seat half of the seat status report.
*
* The count used to be a raw `SeatBlock` row count with an exact `type === 'dining'`
* exclusion. Real EDR data stores display names in `CoachType.type` ('Dining Coach '),
* so dining seats slipped through, and every global block counted even when its coach
* never joined the train. These cases pin the corrected rule.
*/
const SCHEDULE_ID = 'sched-1';
const DEPARTURE = new Date('2026-03-10T06:00:00.000Z');
interface CoachSpec {
id: string;
number: string;
typeType?: string;
typeName?: string;
}
const passengerCoach: CoachSpec = { id: 'coach-1', number: 'C1' };
const diningCoach: CoachSpec = {
id: 'coach-dining',
number: 'D1',
// As the data actually looks: display name in `type`, trailing space included.
typeType: 'Dining Coach ',
typeName: 'Dining Coach',
};
function seatRow(
id: string,
coach: CoachSpec,
seatNumber: string,
bedPosition: string | null = null,
) {
return {
id,
coachId: coach.id,
seatNumber,
bedPosition,
coach: {
number: coach.number,
coachType: {
name: coach.typeName ?? 'Standard',
type: coach.typeType ?? 'passenger',
seatClasses: [{ name: 'Economy', bedPosition: null }],
},
},
};
}
function blockRow(
overrides: Partial<{
id: string;
scheduleId: string | null;
reason: string;
blockedAt: Date;
unblockAt: Date | null;
seat: ReturnType<typeof seatRow>;
}> = {},
) {
return {
id: 'block-1',
scheduleId: SCHEDULE_ID as string | null,
reason: 'VIP hold',
blockedBy: 'user-1',
blockedAt: new Date('2026-03-01T00:00:00.000Z'),
unblockAt: null as Date | null,
seat: seatRow('seat-1', passengerCoach, '1'),
...overrides,
};
}
function makeService(opts: {
blocks: ReturnType<typeof blockRow>[];
assignedCoachIds?: string[];
departureAt?: Date | null;
bookingSeats?: any[];
}) {
const prisma = {
bookingSeat: { findMany: jest.fn().mockResolvedValue(opts.bookingSeats ?? []) },
seatHold: { findMany: jest.fn().mockResolvedValue([]) },
trainSchedule: {
findUnique: jest.fn().mockResolvedValue(
opts.departureAt === null ? null : { departureAt: opts.departureAt ?? DEPARTURE },
),
},
coachAssignment: {
findMany: jest
.fn()
.mockResolvedValue(
(opts.assignedCoachIds ?? [passengerCoach.id, diningCoach.id]).map((coachId) => ({
coachId,
})),
),
},
seatBlock: { findMany: jest.fn().mockResolvedValue(opts.blocks) },
};
return {
service: new ReportsService(prisma as any, {} as any, {} as any),
prisma,
};
}
describe('getSeatStatusReport — blocked seats', () => {
it('counts a schedule-scoped block on a passenger coach', async () => {
const { service } = makeService({ blocks: [blockRow()] });
const report = await service.getSeatStatusReport(SCHEDULE_ID);
expect(report.summary.blockedCount).toBe(1);
expect(report.blockedSeats).toHaveLength(1);
expect(report.blockedSeats[0]).toMatchObject({ coachNumber: 'C1', seatNumber: '1' });
});
it('leaves out a dining coach whose type carries a display name', async () => {
const { service } = makeService({
blocks: [blockRow({ seat: seatRow('seat-d', diningCoach, '1') })],
});
const report = await service.getSeatStatusReport(SCHEDULE_ID);
expect(report.summary.blockedCount).toBe(0);
expect(report.blockedSeats).toEqual([]);
});
it('leaves out placeholder seats', async () => {
const { service } = makeService({
blocks: [blockRow({ seat: seatRow('seat-p', passengerCoach, '-1') })],
});
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
});
it('counts a global block on a coach assigned to this train', async () => {
const { service } = makeService({
blocks: [blockRow({ scheduleId: null })],
assignedCoachIds: [passengerCoach.id],
});
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(1);
});
it('ignores a global block whose coach never joined this train', async () => {
const { service } = makeService({
blocks: [blockRow({ scheduleId: null })],
assignedCoachIds: ['some-other-coach'],
});
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
});
it('ignores a global block that had already been lifted by departure', async () => {
const { service } = makeService({
blocks: [
blockRow({
scheduleId: null,
unblockAt: new Date('2026-03-05T00:00:00.000Z'),
}),
],
assignedCoachIds: [passengerCoach.id],
});
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
});
it('ignores a global block created after departure', async () => {
const { service } = makeService({
blocks: [blockRow({ scheduleId: null, blockedAt: new Date('2026-03-20T00:00:00.000Z') })],
assignedCoachIds: [passengerCoach.id],
});
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
});
it('counts a seat blocked both globally and for this schedule once', async () => {
const seat = seatRow('seat-1', passengerCoach, '1');
const { service } = makeService({
blocks: [
blockRow({ id: 'block-schedule', seat, reason: 'Crew seat' }),
blockRow({ id: 'block-global', scheduleId: null, seat, reason: 'Broken armrest' }),
],
assignedCoachIds: [passengerCoach.id],
});
const report = await service.getSeatStatusReport(SCHEDULE_ID);
expect(report.summary.blockedCount).toBe(1);
// The schedule-scoped block is the more specific statement, so it is the one shown.
expect(report.blockedSeats[0].reason).toBe('Crew seat');
});
it('reports nothing blocked when the schedule does not exist', async () => {
const { service } = makeService({
blocks: [blockRow({ scheduleId: null })],
departureAt: null,
});
expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
});
it('asks the database only for non-maintenance, non-ticketing blocks', async () => {
const { service, prisma } = makeService({ blocks: [] });
await service.getSeatStatusReport(SCHEDULE_ID);
const where = prisma.seatBlock.findMany.mock.calls[0][0].where;
expect(where.NOT).toEqual([
{ reason: { startsWith: 'MAINTENANCE:' } },
{ reason: { startsWith: 'Booked in tickets' } },
]);
});
});
describe('getSeatStatusReport — booked seats', () => {
const booking = {
bookingRef: 'BK-1',
status: 'CONFIRMED',
totalMinor: 20000,
currency: 'ETB',
createdAt: new Date('2026-03-01T00:00:00.000Z'),
paymentIntent: { status: 'SUCCEEDED' },
};
it('keeps dining-coach seats out of the paid and unpaid counts', async () => {
const { service } = makeService({
blocks: [],
bookingSeats: [
{
passengerName: 'Abebe',
passengerCategory: 'ADULT',
fareMinor: 20000,
booking,
seat: seatRow('seat-1', passengerCoach, '1'),
},
{
passengerName: 'Diner',
passengerCategory: 'ADULT',
fareMinor: 0,
booking,
seat: seatRow('seat-d', diningCoach, '1'),
},
{
passengerName: 'Kebede',
passengerCategory: 'ADULT',
fareMinor: 20000,
booking: { ...booking, status: 'PENDING_PAYMENT', paymentIntent: null },
seat: seatRow('seat-2', passengerCoach, '2'),
},
],
});
const report = await service.getSeatStatusReport(SCHEDULE_ID);
expect(report.summary.paidCount).toBe(1);
expect(report.summary.unpaidCount).toBe(1);
expect(report.paidSeats.map((s) => s.passengerName)).toEqual(['Abebe']);
});
});

View File

@@ -10,9 +10,7 @@ import {
Banknote,
ArrowRight,
ScanLine,
Ban,
} from "lucide-react";
import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types";
import { dashboardApi } from "@/lib/api/dashboard";
import { apiClient } from "@/lib/api-client";
import { formatCurrency } from "@/lib/utils";
@@ -163,10 +161,6 @@ function DashboardPageContent() {
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0);
const blockedLoss = stats?.blockedSeatRevenueLoss;
// Never summed across currencies — each is shown on its own line, largest first.
const blockedLossRows = blockedLoss?.lossByCurrency ?? [];
const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows);
@@ -326,73 +320,6 @@ function DashboardPageContent() {
</>
)}
</div>
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
dashboard makes no extra request for it. */}
<div className="card flex flex-col gap-3 border-rose-200 bg-rose-50/60 dark:border-rose-900/50 dark:bg-rose-950/20">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-rose-100 dark:bg-rose-900/40 p-1.5">
<Ban className="h-4 w-4 text-rose-600 dark:text-rose-400" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-rose-700 dark:text-rose-400">
Blocked Seats / Revenue Not Collected
</span>
<span className="ml-auto text-[11px] text-muted-foreground">
{blockedLoss?.periodDays ? `Last ${blockedLoss.periodDays}d` : "All time"}
</span>
</div>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (
<>
{blockedLossRows.length === 0 ? (
<p className="text-3xl font-bold text-foreground tabular-nums">
{formatCurrency(0, "ETB")}
</p>
) : (
blockedLossRows.map((row, i) => (
<p
key={row.currency}
className={
i === 0
? "text-3xl font-bold text-rose-600 dark:text-rose-400 tabular-nums"
: "text-lg font-semibold text-rose-600/80 dark:text-rose-400/80 tabular-nums"
}
>
{formatCurrency(row.estimatedLossMinor, row.currency)}
</p>
))
)}
<p className="text-xs text-muted-foreground -mt-1">
Estimated potential revenue never earned
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Seats blocked</span>
<span className="text-sm font-semibold text-foreground tabular-nums">
{(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "}
{(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Top reason</span>
<span className="text-sm font-semibold text-foreground">
{blockedLoss?.topReasonCategory
? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ??
blockedLoss.topReasonCategory)
: "—"}
</span>
</div>
</div>
<Link
href="/reports/blocked-seats"
className="flex items-center justify-center gap-1.5 rounded-md bg-rose-600 hover:bg-rose-700 dark:bg-rose-600 dark:hover:bg-rose-500 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors mt-auto"
>
View full report <ArrowRight className="h-3.5 w-3.5" />
</Link>
</>
)}
</div>
</div>
{/* Revenue breakdown */}

View File

@@ -1,5 +1,4 @@
import { apiClient } from '@/lib/api-client';
import type { BlockedSeatRevenueLossStat } from '@edr/types';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
@@ -13,7 +12,6 @@ export const dashboardApi = {
totalPackageTickets: number;
totalPassengers: number;
blockedSeatsCount: number;
blockedSeatRevenueLoss: BlockedSeatRevenueLossStat;
revenueByCurrency: { currency: string; totalMinor: number }[];
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
}>('/dashboard/backoffice-stats');

View File

@@ -1,4 +1,4 @@
import { createRequire } from 'module';
import { createRequire } from "module";
const require = createRequire(import.meta.url);

View File

@@ -86,5 +86,5 @@ export default {
},
},
plugins: [],
};
};

View File

@@ -0,0 +1,119 @@
-- Position-type grant mapping for the granular permission system rollout.
-- Grants the NEW granular keys to every hand-curated position type that holds
-- the old broad key whose routes the new keys took over. Idempotent (unique
-- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING).
--
-- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so
-- EdrOrgSeeder has created the new permission rows this script references.
-- Running it too early is not destructive but silently under-applies: keys that
-- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed,
-- because invoices:view/export and payments:view already exist on dev). Re-run
-- after seeding — it is safe to run any number of times.
--
-- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then
-- this script → 42 rows inserted, second run → 0 rows, final per-key grant
-- counts identical to the reference environment. Per-user API probes across 20
-- departmental test accounts confirm the keys resolve through /me and gate
-- routes correctly.
BEGIN;
-- Helper shape used throughout:
-- holders of <old key> => also grant <new keys>
-- 1. bookings:view holders => dashboard/read keys that replaced blanket access
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:bookings:view'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:overview:view',
'edr_freight_app:reports:view',
'edr_freight_app:invoices:view',
'edr_freight_app:invoices:export',
'edr_freight_app:payments:view'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 2. train_scheduling:update holders => the write actions split out of it
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:train_scheduling:update'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:train_scheduling:dispatch',
'edr_freight_app:train_scheduling:mark_paid',
'edr_freight_app:train_scheduling:expire_booking'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 3. GL Djibouti clearance holders => final-invoice raise + confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:contracts:final_invoice_raise',
'edr_freight_app:contracts:final_invoice_confirm'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 4. GL Ethiopia clearance holders => final-invoice confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_et_actions'
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 5. Contract-intake holders (any staff_accept flavour) => edit_document
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key IN (
'edr_freight_app:bookings:staff_accept',
'edr_freight_app:contracts:staff_accept:bulk',
'edr_freight_app:contracts:staff_accept:container'
)
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 6. Support inbox ownership (decision 2026-08-07): marketing department types
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT pt.id, p.id
FROM iam.position_types pt
CROSS JOIN iam.permissions p
WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%')
AND p.key IN (
'edr_freight_app:support:agent_view',
'edr_freight_app:support:agent_send'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 7. Companion view keys.
-- Most freight controllers carry a class-level `<module>:view` guard, and Nest
-- runs class AND method guards — so a type holding only `<module>:<action>` is
-- denied before the action key is ever checked. Grant the module's view key
-- alongside every action key the type already holds. View-only, so it widens
-- reads within a module the type already operates in, never across modules.
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pview.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pact ON pact.id = ptp.permission_id
AND pact.key LIKE 'edr_freight_app:%'
JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view')
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
COMMIT;
-- Verification: expected non-zero counts per new key after running.
-- SELECT p.key, count(*) FROM iam.position_type_permissions ptp
-- JOIN iam.permissions p ON p.id = ptp.permission_id
-- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view',
-- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise')
-- GROUP BY p.key;