diff --git a/.github/scripts/scan.js b/.github/scripts/scan.js index 7b1924b3f..2526dff2b 100644 --- a/.github/scripts/scan.js +++ b/.github/scripts/scan.js @@ -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 400–600. + 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", ])'); + 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. = require"); + if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded)) + hits.push("global. = 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; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bdb4c5ff5..b1c797dc6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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: @@ -102,6 +110,7 @@ jobs: DEPLOY_USER: tria DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} steps: - name: Checkout @@ -127,10 +136,10 @@ jobs: ;; esac - - name: Sync environment from server + - name: Sync environment from Env manager app run: | chmod +x scripts/deploy/*.sh - ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}" - name: Set compose project name run: | diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml new file mode 100644 index 000000000..15ca4faeb --- /dev/null +++ b/.github/workflows/malware-scan.yml @@ -0,0 +1,161 @@ +name: Malware Scan + +# Supply-chain malware gate for the PolinRider / Famous Chollima campaign. +# +# Runs standalone on every push and pull request, and is also called by +# deploy.yml as a required first job — a detection fails this workflow, which +# blocks every downstream deploy job from starting. + +on: + push: + # dev and staging are already gated through deploy.yml's required + # malware-scan job — no need to scan those pushes twice. + branches-ignore: + - dev + - staging + pull_request: + workflow_call: + secrets: + TELEGRAM_BOT_TOKEN: + required: false + TELEGRAM_CHAT_ID: + required: false + +permissions: + contents: read + +# A detection on a ref should not be raced by a newer run of the same ref. +concurrency: + group: malware-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan for PolinRider malware + # Plain `self-hosted` — GitHub applies this label to every self-hosted + # runner automatically. The scan is host-agnostic, unlike the deploy jobs + # which pin to a branch-specific runner. + runs-on: [self-hosted, dev] + outputs: + infected: ${{ steps.scan.outputs.infected }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify scanner rules still work + # Fails if someone weakens a detection rule or introduces a false + # positive against minified vendor bundles. + run: node .github/scripts/scan.js --self-test + + - name: Scan repository + id: scan + run: | + set -uo pipefail + + # Actions runs this with `bash -e`, so the non-zero exit must be + # caught with `||` rather than read back from $? afterwards. + STATUS=0 + node .github/scripts/scan.js --json --output malware-report.json . || STATUS=$? + + if [ "$STATUS" -eq 0 ]; then + echo "infected=false" >> "$GITHUB_OUTPUT" + echo "No malware detected." + exit 0 + fi + + echo "infected=true" >> "$GITHUB_OUTPUT" + + # Human-readable run for the log, so the failure is legible in the UI. + node .github/scripts/scan.js . || true + exit 1 + + - name: Build alert message + id: message + if: failure() && steps.scan.outputs.infected == 'true' + run: | + set -euo pipefail + + FILES=$(jq -r '.results[].filePath' malware-report.json | head -20) + COUNT=$(jq -r '.infectedFiles' malware-report.json) + RULES=$(jq -r '[.results[].findings[] | select(.severity=="CRITICAL") | .id] | unique | join(", ")' malware-report.json) + + { + echo "message<> "$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 diff --git a/.gitignore b/.gitignore index 977a34353..8fa8bca90 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,13 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat + +# private keys / certificates (EIMS INSA credentials and anything like them) — never commit +*.key +*.pem +*.pem.txt +*.p12 +*.pfx +*.crt +secrets/ +certs/ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 16ee9cd57..2c636eee4 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -76,6 +76,12 @@ SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false +# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key). +# Unset = every employee. A key matching no organization returns no users. +# Dev seed key: edr_freight +# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001 +FREIGHT_ORG_KEY=edr_freight + # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost MINIO_PORT=9000 @@ -122,3 +128,79 @@ 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 + +# ── MoR EIMS e-invoicing (core.mor.gov.et) ───────────────────────────────── +# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled. +EIMS_ENABLED=false +EIMS_BASE_URL=https://core.mor.gov.et +EIMS_CLIENT_ID= +EIMS_CLIENT_SECRET= +EIMS_API_KEY= +EIMS_TIN= +# Source-system identity comes from the access token's systemNumber/systemType claims. +# Setting these turns them into expected-value checks: a mismatch against the token fails +# fast rather than one side silently winning. Leave empty to take the gateway's word. +EIMS_SYSTEM_NUMBER= +EIMS_SYSTEM_TYPE= +# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file +# patterns are gitignored, but a path outside the working tree is safer still. +# The certificate is transmitted as base64 of this file's exact bytes — do not convert it. +EIMS_PRIVATE_KEY_PATH= +EIMS_CERTIFICATE_PATH= +# Optional tuning +EIMS_HTTP_TIMEOUT_MS=30000 +EIMS_TOKEN_SKEW_SECONDS=45 + +# ── EIMS invoice registration (required only to register invoices) ───────── +# Seller identity: EDR's own legal details are not modelled anywhere in the DB. +# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names. +EIMS_SELLER_LEGAL_NAME= +EIMS_SELLER_VAT_NUMBER= +EIMS_SELLER_PHONE= +EIMS_SELLER_EMAIL= +EIMS_SELLER_REGION= +EIMS_SELLER_WEREDA= +# Optional seller address parts; sent as null when unset. +EIMS_SELLER_CITY= +EIMS_SELLER_SUBCITY= +EIMS_SELLER_HOUSE_NUMBER= +EIMS_SELLER_LOCALITY= +# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all +# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails +# locally, naming the missing variables, until these are set. +# Required, and deliberately unset: the choice is a tax position, not a default. +# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH +# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +EIMS_TAX_CODE= +EIMS_TAX_RATE_PERCENT=0 +EIMS_EXCISE_TAX_VALUE=0 +EIMS_INCOME_WITHHOLD_VALUE=0 +EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Document classification and payment presentation. +EIMS_TRANSACTION_TYPE=B2B +# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. +EIMS_NATURE_OF_SUPPLIES=service +EIMS_PAYMENT_MODE=CASH +EIMS_PAYMENT_TERM=IMMIDIATE +EIMS_UNIT_DEFAULT=PCS +# MoR numeric country code for the buyer; our companies store the country name. +EIMS_BUYER_COUNTRY_CODE= +# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$. +# An unmapped region fails locally rather than being filed with a guess. +EIMS_BUYER_REGION_CODES=Addis Ababa=13 +# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is +# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess. +EIMS_BUYER_WEREDA_CODES= +EIMS_CASHIER_NAME= +EIMS_SALESPERSON_NAME= +# Automatic filing of issued invoices (@Cron sweep, one invoice per tick). +# Independent of EIMS_ENABLED on purpose: authentication can be live long before +# filing is. Both must be true before anything is submitted automatically. +EIMS_AUTO_SUBMIT=false +EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * +# MoR rejects documents older than 3 days; the sweep will not attempt those. +EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c4837c8a7..fb6a0bd31 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -37,7 +37,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migration:run": "nest build && node dist/scripts/migrate.js", - "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts", + "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d41b77dce..c1b890719 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; +import eimsConfig from "./config/eims.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -49,11 +50,11 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; +import { SupportContentModule } from "./modules/support-content/support-content.module"; 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, @@ -67,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { SupportContentSeeder } from "./seed/support-content.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; @@ -77,13 +79,15 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; // import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; // import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; +import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; -import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; -import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; +// import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +// import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; @@ -100,6 +104,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; import { ComplianceModule } from "./modules/compliance/compliance.module"; import { IncidentsModule } from "./modules/incidents/incidents.module"; import { ProcurementModule } from "./modules/procurement/procurement.module"; +import { FacilitiesModule } from "./modules/facilities/facilities.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; @@ -110,6 +115,7 @@ import { AiModule } from "./modules/ai/ai.module"; import { AuditModule } from "./modules/audit/audit.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; +import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; if (!process.env.APPLICATION_NAME) { process.env.APPLICATION_NAME = "freight"; @@ -125,6 +131,7 @@ if (!process.env.APPLICATION_NAME) { telebirrConfig, rabbitmqConfig, faydaConfig, + eimsConfig, ], }), ScheduleModule.forRoot(), @@ -215,11 +222,11 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, ContractTemplatesModule, + SupportContentModule, OtpModule, HealthModule, RuleEngineModule, BackofficeModule, - DemoPermissionsModule, FreightAuthModule, PaymentModule, //New Modules @@ -239,6 +246,7 @@ if (!process.env.APPLICATION_NAME) { ComplianceModule, IncidentsModule, ProcurementModule, + FacilitiesModule, GpsTrackingModule, FirstMileModule, LastMileModule, @@ -246,6 +254,7 @@ if (!process.env.APPLICATION_NAME) { InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + EimsModule, FleetHistoryModule, AiModule, AuditModule, @@ -254,8 +263,10 @@ if (!process.env.APPLICATION_NAME) { EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + SupportContentSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, + FreightNotificationPermissionsSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, // FreightStaffUsersSeeder, @@ -270,9 +281,12 @@ if (!process.env.APPLICATION_NAME) { // WarehouseDemoSeeder, // ExportDjiboutiInterchangeDemoSeeder, // MarshallingDemoTrainsSeeder, - ApprovedFirstLastMileDemoBookingsSeeder, - PaidImportExportMileDemoSeeder, + // ApprovedFirstLastMileDemoBookingsSeeder, + // PaidImportExportMileDemoSeeder, LoginAudienceMiddleware, + // Feeds position-TYPE grants to the synchronous permission checks — without + // it, staff whose permissions live on their position type resolve to none. + PositionTypePermissionsCache, ], }) export class AppModule implements OnApplicationBootstrap { @@ -281,8 +295,10 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly supportContentSeeder: SupportContentSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, + private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, @@ -324,10 +340,24 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); + // freightNotificationPermissions → seeds the :get_notification + // keys and backfills them onto whoever + // already holds each desk's anchor + // permission. Runs LAST in this block so + // it sees a freshly-seeded catalog and + // freshly-seeded positions. Unlike the + // seeders above it is NOT gated behind + // SEED_EDR_ORG — without it every staff + // notification resolves to no one. + await this.freightNotificationPermissionsSeeder.run(); // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so + // it fills an empty table once and never touches admin edits afterwards. + await this.supportContentSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Dire Dawa). Idempotent; creates no yards. // await this.yardFacilitiesSeeder.run(); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 854594ffc..d1b5364c3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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() or MixedAudience(); kept for routes not yet swept. */ -export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); +export const StaffReference = () => + applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([]))); + +/** Portal routes: customer accounts only; ownership scoping stays in services. */ +export const PortalCustomer = () => + applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard)); + +/** + * Routes both audiences call (sign, shared document reads, handover): staff + * need one of the given permissions, customers pass through to the service's + * ownership checks. + */ +export const MixedAudience = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + MixedAudienceGuard( + Array.isArray(permission) ? permission : [permission], + ), + ), + ); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); @@ -31,6 +57,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const BookingDocReviewAlert = () => BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert); +/** Staff wagon-cancellation history list (admin side). */ +export const WagonCancellationView = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView); + +/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */ +export const WagonCancellationVoid = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid); + +/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */ +export const WagonCancellationRebook = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook); + export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); @@ -57,9 +95,14 @@ export const TrainSchedulingRulesManage = () => * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain * valid as a one-of fallback so existing role grants keep working. */ -export const FleetView = (granular?: string) => +export const FleetView = (granular?: string | string[]) => BookingStaff( - granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + granular + ? [ + ...(Array.isArray(granular) ? granular : [granular]), + FREIGHT_PERMS.fleet.view, + ] + : FREIGHT_PERMS.fleet.view, ); export const FleetManage = (granular?: string) => diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts index 6aaa24a26..62e7c758c 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.spec.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => { assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), ).resolves.toBeUndefined(); }); + + it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { + id: 'b-1', + tradeDirection: 'EXPORT', + exportHandoverMode: 'DIRECT_TO_TRAIN', + }), + ).resolves.toBeUndefined(); + // Direct short-circuits before querying — there is no inventory to look for. + expect(source.query as jest.Mock).not.toHaveBeenCalled(); + }); + + it('still gates a warehouse export booking', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { + id: 'b-1', + tradeDirection: 'EXPORT', + exportHandoverMode: 'WAREHOUSE', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts index 0e1728800..5fd97480a 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm'; export interface ExportLoadGateBooking { id: string; tradeDirection?: string | null; + /** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */ + exportHandoverMode?: string | null; } +/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */ +export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN'; +/** Warehouse-then-train: the existing flow. Also what a null mode means. */ +export const WAREHOUSE = 'WAREHOUSE'; + /** * Export cargo may not be loaded onto its train until it has physically reached * the warehouse and been issued a GRN — whether it got there by first-mile or by @@ -21,12 +28,18 @@ export interface ExportLoadGateBooking { * "Received with a GRN" = an inventory row that has reached the warehouse * (RECEIVED or any later stage) and carries a GRN, in the column or the notes * fallback older rows use. + * + * Export has a second, warehouse-free shape: the customer's truck loads straight + * onto the wagon. That cargo is never received and never GRN'd, so a booking + * marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is + * attested by the carriage acceptance sheet instead. */ export async function assertExportReceivedWithGrn( db: DataSource | EntityManager, booking: ExportLoadGateBooking, ): Promise { if (booking.tradeDirection !== 'EXPORT') return; + if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return; const [row] = await db.query( `SELECT 1 diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index 68def6440..9cb002891 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -8,7 +8,48 @@ 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'; +import { readTwinOf } from '../seed/freight-permissions.registry'; + +// 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); + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Does the caller satisfy a required permission? + * + * Holding the key outright always passes. A required `:view` is ALSO + * satisfied by the weaker `:read` — the key that buys API reads + * without putting the module in the backoffice sidebar — but only on a safe + * HTTP method. + * + * The method restriction is load-bearing, not caution. Nest runs class AND + * method guards, so controllers list every route key on the class gate, + * `:view` among the write keys. Without this check a `:read` holder would + * clear that class gate and then reach any write route that has no method + * gate of its own. Keying on the HTTP verb closes that by construction rather + * than by an audit that goes stale the next time a route is added. + */ +const satisfiedBy = ( + user: TCurrentUser, + required: string, + method: string, +): boolean => { + if (hasFreightPermission(user, required)) return true; + if (!SAFE_METHODS.has(method)) return false; + const readTwin = readTwinOf(required); + return Boolean(readTwin && hasFreightPermission(user, readTwin)); +}; export function FreightPermissionGuard( permissions: string[], @@ -16,15 +57,20 @@ export function FreightPermissionGuard( @Injectable() class FreightPermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); 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.some((p) => hasFreightPermission(user, p))) { + if (!permissions?.length) return true; + if (permissions.some((p) => satisfiedBy(user, p, request.method))) { return true; } @@ -36,3 +82,59 @@ 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 { + @Injectable() + class MixedAudiencesGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); + 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) => satisfiedBy(user, p, request.method)) + ) { + return true; + } + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return MixedAudiencesGuard; +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts index d0d01535a..f3931f78a 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -1,6 +1,9 @@ import { assertCanApproveContractStep, canEditContractStep, + collectPermissionKeys, + hasFreightPermission, + setPositionTypePermissionResolver, } from './freight-permission.util'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; @@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => { ); }); }); + +/** + * The GL lockout regression: positions created through the admin UI keep their + * grants on the position TYPE, and the JWT only ever snapshots DIRECT position + * permissions. Without the type resolver those staff resolved to zero + * permissions, so every gated route rejected them — which is what kept GL + * officers out of their own clearance detail pages. + */ +describe('collectPermissionKeys — position-type grants', () => { + const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview; + + afterEach(() => { + setPositionTypePermissionResolver(() => []); + }); + + const glOfficer = { + roles: [], + permissions: [], + employee: { + position: { + permissions: [], // admin-created position carries NO direct grants + positionType: { key: 'commercial-global-logistics-(et)-officer' }, + }, + }, + }; + + it('resolves permissions carried by the position type', () => { + setPositionTypePermissionResolver((key) => + key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [], + ); + + expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE); + expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true); + }); + + it('handles the array-shaped employee payload too', () => { + setPositionTypePermissionResolver(() => [CLEARANCE]); + + const arrayShaped = { + roles: [], + permissions: [], + employee: [ + { + positions: [ + { permissions: [], positionType: { key: 'djibouti-gl-officer' } }, + ], + }, + ], + }; + + expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true); + }); + + it('still rejects when neither the position nor its type grants it', () => { + setPositionTypePermissionResolver(() => []); + + expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false); + }); + + it('keeps direct position permissions working with no resolver installed', () => { + const direct = { + roles: [], + permissions: [], + employee: { position: { permissions: [{ key: CLEARANCE }] } }, + }; + + expect(hasFreightPermission(direct, CLEARANCE)).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 56c0e77c2..bc98a6df4 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo return isSuperAdmin(user) || isOrganizationAdmin(user); } -/** Flat permission keys from JWT / session user (roles + position permissions). */ +/** + * Permissions carried by a position TYPE rather than the position itself. + * + * The JWT snapshots only DIRECT position permissions, so type-level grants — + * which is where admin-created positions keep theirs — are absent from the + * token entirely. This resolver is installed at startup + * (see `PositionTypePermissionsCache`) so the synchronous permission checks + * below can still see them. Left as a no-op resolver until then, which + * degrades to the old position-only behaviour rather than throwing. + */ +let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () => + []; + +export function setPositionTypePermissionResolver( + resolver: (positionTypeKey: string) => string[], +): void { + positionTypePermissionResolver = resolver; +} + +/** + * Flat permission keys from JWT / session user: roles, position permissions, + * and the grants held by each position's TYPE. + */ export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { if (!user) return []; const keys = new Set(); + const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => { + if (!positionType?.key) return; + for (const key of positionTypePermissionResolver(positionType.key)) { + keys.add(key); + } + }; + for (const p of user.permissions ?? []) { if (p.key) keys.add(p.key); } @@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri for (const p of pos.permissions ?? []) { if (p.key) keys.add(p.key); } + addTypePermissions(pos.positionType); } } return [...keys]; @@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri for (const p of employee.position?.permissions ?? []) { if (p.key) keys.add(p.key); } + addTypePermissions(employee.position?.positionType); for (const delegated of employee.delegatedPositions ?? []) { for (const p of delegated.permissions ?? []) { if (p.key) keys.add(p.key); diff --git a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts index 9165e54d5..2d2863dd5 100644 --- a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts +++ b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts @@ -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; } diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts index e2ff1bfd9..e60bf4f4e 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts @@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => { expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' }); }); + it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null }); + const near = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 12, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 }); + const boundary = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 30, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(boundary).toMatchObject({ total: 10 * 30 * 22 }); + }); + + it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const fallback = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear, bulkRate], // bulkRate has no band + }); + expect(fallback).toMatchObject({ total: 10 * 50 * 25 }); + expect( + computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear], + }), + ).toBeNull(); + }); + it('returns null on mixed currencies, unknown km, and uncovered freight types', () => { const usd40 = rate({ ...band40a, currency: 'USD' }); expect( diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.ts index 706e21238..055e8e5f9 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.ts @@ -30,10 +30,12 @@ const round2 = (n: number): number => Math.round(n * 100) / 100; /** * Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in. * - * BULK: one PER_TON_KM rate → price = tons × km × rate. + * BULK: the PER_TON_KM rate whose distance band holds the km (a legacy + * bandless row — NULL minKm — is the fallback and prices every distance) → + * price = tons × km × rate. * CONTAINER: per container size, the PER_KM rate whose distance band holds the - * km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price = - * km × rate × quantity, summed across sizes. + * km → price = km × rate × quantity, summed across sizes. + * Bands are half-open [minKm, maxKm), NULL maxKm = open-ended. * * Returns null whenever the rules don't fully cover the shipment (no rate, a * container size without a matching band, mixed currencies, km/tons unknown) — @@ -56,7 +58,15 @@ export function computeLastMileCharge(input: { if (freightType === 'BULK') { if (!tons || tons <= 0) return null; - const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM'); + const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM'); + const rate = + bulkRates.find( + (r) => + r.minKm !== null && + r.minKm !== undefined && + Number(r.minKm) <= km && + (r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)), + ) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined); if (!rate) return null; const unitRate = Number(rate.rateValue); const amount = round2(tons * km * unitRate); diff --git a/apps/edr-freight-api/src/common/position-type-permissions.cache.ts b/apps/edr-freight-api/src/common/position-type-permissions.cache.ts new file mode 100644 index 000000000..2848ed0b2 --- /dev/null +++ b/apps/edr-freight-api/src/common/position-type-permissions.cache.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { setPositionTypePermissionResolver } from './freight-permission.util'; + +/** + * Permissions granted to a position TYPE (`iam.position_type_permissions`). + * + * A position type is the platform's notion of a role, and positions created + * through the admin UI carry their grants there rather than on the position + * itself. The JWT only ever snapshots DIRECT position permissions, so those + * grants are invisible to `collectPermissionKeys` — staff on such a position + * resolve to zero permissions and every permission-gated route rejects them. + * + * The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are + * synchronous and sit on the request path, so the mapping is held in memory and + * refreshed periodically rather than queried per request. The dataset is tiny + * (tens of types, a few hundred rows), so a full reload is cheaper than any + * incremental scheme. + */ +@Injectable() +export class PositionTypePermissionsCache implements OnModuleInit { + private readonly logger = new Logger(PositionTypePermissionsCache.name); + + /** position_type key → permission keys. Empty until the first load lands. */ + private byPositionTypeKey = new Map(); + + // ponytail: fixed 5-min refresh, no invalidation hook. A permission granted + // in the admin UI takes up to one interval to reach the guards. Wire the + // grant mutation to call `refresh()` if that lag ever matters. + private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000; + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async onModuleInit(): Promise { + await this.refresh(); + // Hand the lookup to the permission utils, whose checks are synchronous and + // therefore cannot query IAM themselves. + setPositionTypePermissionResolver((positionTypeKey) => + this.get(positionTypeKey), + ); + const timer = setInterval(() => { + void this.refresh(); + }, PositionTypePermissionsCache.REFRESH_INTERVAL_MS); + // Never hold the process open for a cache refresh. + timer.unref?.(); + } + + /** Permission keys for a position-type key ([] when unknown/not loaded). */ + get(positionTypeKey: string | undefined | null): string[] { + if (!positionTypeKey) return []; + return this.byPositionTypeKey.get(positionTypeKey) ?? []; + } + + /** Reload the whole mapping. Failures keep the previous snapshot in place. */ + async refresh(): Promise { + try { + const rows: { position_type_key: string; permission_key: string }[] = + await this.dataSource.query( + `SELECT pt.key AS position_type_key, perm.key AS permission_key + FROM iam.position_type_permissions ptp + JOIN iam.position_types pt ON pt.id = ptp.position_type_id + JOIN iam.permissions perm ON perm.id = ptp.permission_id`, + ); + + const next = new Map(); + for (const row of rows) { + if (!row.position_type_key || !row.permission_key) continue; + const keys = next.get(row.position_type_key); + if (keys) keys.push(row.permission_key); + else next.set(row.position_type_key, [row.permission_key]); + } + this.byPositionTypeKey = next; + } catch (err) { + // iam schema unreachable — keep serving the previous snapshot rather than + // dropping every type-derived permission and locking staff out. + this.logger.warn( + `Position-type permission refresh failed: ${(err as Error).message}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 5de529d15..af4a5b017 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -215,8 +215,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions { }; } -export default registerAs("database", (): TypeOrmModuleOptions => ({ - ...buildDataSourceOptions(), - autoLoadEntities: true, - migrationsRun: false, -})); +export default registerAs( + "database", + (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + migrationsRun: false, + }), +); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts new file mode 100644 index 000000000..a8a929ca5 --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -0,0 +1,205 @@ +import { registerAs } from "@nestjs/config"; + +/** + * Ethiopian MoR EIMS e-invoicing gateway. + * + * Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS + * service throws a clear error on use, so a deployment without credentials still boots. + * + * Secrets (client secret, API key) and the credential file paths live only here and are never + * logged — validation reports missing variable *names*, never their values. + */ +export interface EimsConfig { + enabled: boolean; + baseUrl: string; + clientId: string; + clientSecret: string; + apiKey: string; + tin: string; + /** + * Optional *expectations* for the source-system identity, not inputs. + * + * The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials + * that authenticated, and those are what registration uses. When these are set they are compared + * against the token and a mismatch fails fast — neither side silently wins. Leave them empty to + * take whatever the gateway says. + */ + systemNumber: string; + systemType: string; + /** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */ + privateKeyPath: string; + /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ + certificatePath: string; + httpTimeoutMs: number; + /** Re-authenticate this many ms before the access token actually expires. */ + tokenSkewMs: number; + /** + * Automatic submission of issued invoices, off by default. + * + * Invoices are produced by the workflow, so the production path is a sweep rather than a human + * action — but enabling it starts filing real documents with the tax authority, which is + * irreversible from our side. It therefore needs its own deliberate switch, separate from + * `EIMS_ENABLED`, so that authentication can be live long before filing is. + */ + autoSubmit: boolean; + autoSubmitCron: string; + /** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */ + autoSubmitMaxAgeDays: number; + /** + * Seller identity and tax/business treatment for the invoice document. + * + * None of this is derivable from the database: EDR's own legal identity exists nowhere in the + * codebase, and the app models no tax at all. Values are required at registration time and are + * validated there rather than at boot, so a deployment can run with EIMS enabled for + * authentication before finance has signed off on the tax treatment. + */ + invoice: EimsInvoiceConfig; +} + +export interface EimsInvoiceConfig { + sellerLegalName: string; + sellerVatNumber: string; + sellerPhone: string; + sellerEmail: string; + /** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */ + sellerRegion: string; + sellerWereda: string; + sellerCity: string | null; + sellerSubCity: string | null; + sellerHouseNumber: string | null; + sellerLocality: string | null; + /** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */ + taxCode: string; + taxRatePercent: number | null; + exciseTaxValue: number | null; + incomeWithholdValue: number | null; + transactionWithholdValue: number | null; + /** B2B / B2C — a tax classification, so it is configured, not inferred. */ + transactionType: string; + natureOfSupplies: string; + paymentMode: string; + paymentTerm: string; + unitDefault: string; + buyerCountryCode: string | null; + /** + * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` + * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails + * locally rather than being filed with a guessed one. + */ + buyerRegionCodes: Record; + /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ + buyerWeredaCodes: Record; + cashierName: string | null; + salesPersonName: string | null; +} + +const REQUIRED_VARS = [ + "EIMS_CLIENT_ID", + "EIMS_CLIENT_SECRET", + "EIMS_API_KEY", + "EIMS_TIN", + "EIMS_PRIVATE_KEY_PATH", + "EIMS_CERTIFICATE_PATH", +] as const; + +const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { + if (raw === undefined || raw === "") return fallback; + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +}; + +/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */ +const parseCodeMap = (raw: string | undefined): Record => { + const map: Record = {}; + for (const pair of (raw ?? "").split(",")) { + const [name, code] = pair.split("="); + if (name?.trim() && code?.trim()) map[name.trim()] = code.trim(); + } + return map; +}; + +/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ +const optionalNumber = (raw: string | undefined, name: string): number | null => { + if (raw === undefined || raw === "") return null; + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`${name} must be a number`); + return value; +}; + +export default registerAs("eims", (): EimsConfig => { + const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true"; + const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, ""); + const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS"); + const tokenSkewMs = + positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000; + + const base: EimsConfig = { + enabled, + baseUrl, + clientId: process.env.EIMS_CLIENT_ID ?? "", + clientSecret: process.env.EIMS_CLIENT_SECRET ?? "", + apiKey: process.env.EIMS_API_KEY ?? "", + tin: process.env.EIMS_TIN ?? "", + systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "", + systemType: process.env.EIMS_SYSTEM_TYPE ?? "", + privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", + certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + httpTimeoutMs, + tokenSkewMs, + autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", + // Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a + // misconfiguration from filing a burst of bad documents before anyone notices. + autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *", + autoSubmitMaxAgeDays: positiveInt( + process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS, + 3, + "EIMS_AUTO_SUBMIT_MAX_AGE_DAYS", + ), + invoice: { + sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "", + sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "", + sellerPhone: process.env.EIMS_SELLER_PHONE ?? "", + sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "", + sellerRegion: process.env.EIMS_SELLER_REGION ?? "", + sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "", + sellerCity: process.env.EIMS_SELLER_CITY || null, + sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null, + sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null, + sellerLocality: process.env.EIMS_SELLER_LOCALITY || null, + taxCode: process.env.EIMS_TAX_CODE ?? "", + taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"), + exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"), + incomeWithholdValue: optionalNumber( + process.env.EIMS_INCOME_WITHHOLD_VALUE, + "EIMS_INCOME_WITHHOLD_VALUE", + ), + transactionWithholdValue: optionalNumber( + process.env.EIMS_TRANSACTION_WITHHOLD_VALUE, + "EIMS_TRANSACTION_WITHHOLD_VALUE", + ), + transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "", + natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "", + paymentMode: process.env.EIMS_PAYMENT_MODE ?? "", + paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", + unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", + buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, + buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), + buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + cashierName: process.env.EIMS_CASHIER_NAME || null, + salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + }, + }; + + if (!enabled) return base; + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, + ); + } + return base; +}); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index d4f0c4039..36e4e34d0 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts new file mode 100644 index 000000000..0d506cff0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Partial wagon cancellation with rebooking credit. + * + * One row per cancellation cycle on a PAID booking: the customer asks to drop + * N wagons, pays a per-wagon cancellation fee (rates row + * rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a + * rebookable credit. The credit is redeemed by creating a fresh booking + * through the normal under-contract create path (which re-checks contract + * validity and caps), immediately marked PAID — the freight was already paid + * on the original booking, only the fee is new money. + * + * cancelled_quantities carries what was cut, in the booking's own terms: + * `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container. + * Container numbers are NOT stored here — they are recovered at rebook time + * from the unit rows the reduction soft-deleted (same hybrid pattern as + * RemainderPlacementService). + */ +export class BookingWagonCancellations3300000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + rebooked_booking_id uuid REFERENCES freight.bookings(id), + wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0), + weight_tons numeric(12,3) NOT NULL DEFAULT 0, + cancelled_quantities jsonb NOT NULL, + credit_amount numeric(14,2) NOT NULL DEFAULT 0, + fee_rate_id uuid REFERENCES freight.rates(id), + fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0), + fee_currency varchar(8) NOT NULL DEFAULT 'ETB', + fee_invoice_id uuid REFERENCES freight.invoices(id), + fee_paid_at timestamptz, + status varchar(30) NOT NULL DEFAULT 'FEE_PENDING', + reason text, + requested_by_user_id uuid, + rebooked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + // One open (fee-unpaid) cancellation per booking — closes the double-click + // race without app-level locking. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking + ON freight.booking_wagon_cancellations (booking_id) + WHERE status = 'FEE_PENDING' AND deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bwc_booking + ON freight.booking_wagon_cancellations (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bwc_status + ON freight.booking_wagon_cancellations (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts new file mode 100644 index 000000000..10c3d4799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Audit trail for wagon status flips (Available ⇄ Maintenance and any other + * bulk-status change): who moved which wagon from what to what, when, and why. + * Written inside the same transaction as the status update itself. + */ +export class WagonStatusLogs3310000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_status_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL REFERENCES freight.wagons(id), + from_status varchar(30) NOT NULL, + to_status varchar(30) NOT NULL, + changed_by_user_id uuid, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_status_logs_wagon + ON freight.wagon_status_logs (wagon_id, created_at DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts new file mode 100644 index 000000000..39a10ca90 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts @@ -0,0 +1,100 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const CONTAINER_CODES = [ + 'IMPORT_CONTAINER_CUSTOMS', + 'IMPORT_CONTAINER_NO_CUSTOMS', + 'EXPORT_CONTAINER_CUSTOMS', + 'EXPORT_CONTAINER_NO_CUSTOMS', + 'INTERCITY_CONTAINER', +]; + +const BULK_CODES = [ + 'IMPORT_BULK_CUSTOMS', + 'IMPORT_BULK_NO_CUSTOMS', + 'EXPORT_BULK_CUSTOMS', + 'EXPORT_BULK_NO_CUSTOMS', + 'INTERCITY_BULK', +]; + +/** + * Bulk contract templates become staff-created, keyed by (cargo type, customs + * clearing) instead of the fixed direction codes. The five container templates + * stay seeded and become undeletable system rows; the five seeded bulk rows are + * retired (soft-deleted). cargo_types gains has_contract_template, marking + * which bulk commodities may carry their own template. + */ +export class BulkContractTemplates3320000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id), + ADD COLUMN IF NOT EXISTS with_customs boolean, + ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false + `); + + // Generated bulk codes (BULK__NO_CUSTOMS) outgrow varchar(40). + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ALTER COLUMN code TYPE varchar(80) + `); + + await queryRunner.query( + `UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`, + [CONTAINER_CODES], + ); + + // Retire the fixed bulk templates; staff recreate them per cargo type. + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = now() + WHERE code = ANY($1) AND deleted_at IS NULL`, + [BULK_CODES], + ); + + // Code stays unique among live rows only, so a deleted combo can be + // recreated under the same generated code. + await queryRunner.query( + `ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code + ON freight.contract_templates (code) WHERE deleted_at IS NULL + `); + + // One template per (bulk cargo type, customs option) — the "same + // combination" rule, enforced even under concurrent creates. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs + ON freight.contract_templates (cargo_type_id, with_customs) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT uq_contract_templates_code UNIQUE (code) + `); + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`, + [BULK_CODES], + ); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP COLUMN IF EXISTS cargo_type_id, + DROP COLUMN IF EXISTS with_customs, + DROP COLUMN IF EXISTS is_system + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts new file mode 100644 index 000000000..1ff9bab35 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS registration state. + * + * `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice + * consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the + * database-level guarantee that one IRN can never be recorded against two invoices, independent of + * application logic. + * + * `freight.eims_system_state` is a single row per MoR system number holding the sequence the + * gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn` + * of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter + * and the IRN chain stay consistent under concurrent submissions. + * + * The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and + * the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves + * evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set + * when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later + * document for this system number would chain to a stale `PreviousIrn` and registration stops until + * a human resolves it. + * + * `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string + * ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored + * verbatim so a compliance value is never mangled by a parse. + */ +export class EimsInvoiceRegistration3330000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + ADD COLUMN IF NOT EXISTS eims_irn varchar(64), + ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint, + ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_last_error jsonb + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn + ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_system_state ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + system_number varchar(32) NOT NULL UNIQUE, + next_invoice_counter bigint NOT NULL DEFAULT 1, + previous_irn varchar(64), + in_flight_invoice_id uuid, + in_flight_counter bigint, + blocked_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_status, + DROP COLUMN IF EXISTS eims_irn, + DROP COLUMN IF EXISTS eims_invoice_counter, + DROP COLUMN IF EXISTS eims_submitted_at, + DROP COLUMN IF EXISTS eims_ack_date, + DROP COLUMN IF EXISTS eims_last_error + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts new file mode 100644 index 000000000..481b7489c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS document numbering. + * + * MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer + * of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs + * its own sequence, allocated from the same locked state row as the invoice counter and recorded + * on the invoice so a filed document can be traced back to it. + */ +export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS in_flight_document_number bigint + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_document_number varchar(16) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number + `); + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + DROP COLUMN IF EXISTS next_document_number, + DROP COLUMN IF EXISTS in_flight_document_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts new file mode 100644 index 000000000..168ee2c6d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Editable customer-facing copy for the portal's public pages (/help, /faq, + * /terms, /privacy) plus the shared support-contact block, with an append-only + * version log behind it. + * + * `payload` is opaque jsonb: the five documents have genuinely different shapes + * and the help page's blocks change with the copy, so typed columns would mean + * a migration per wording tweak. The shape is enforced by per-slug DTOs on + * write instead. + * + * No rows are inserted here — `SupportContentSeeder` fills the table on first + * boot and skips whenever it is non-empty, so a redeploy never overwrites + * admin edits the way a migration-embedded INSERT eventually would. + */ +export class SupportContent3350000000000 implements MigrationInterface { + name = "SupportContent3350000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug varchar(32) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + version integer NOT NULL DEFAULT 1, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_documents_slug + ON freight.support_documents (slug); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_document_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + document_id uuid NOT NULL + REFERENCES freight.support_documents(id) ON DELETE CASCADE, + version integer NOT NULL, + payload jsonb NOT NULL, + actor_id uuid, + note varchar(255), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Closes the concurrent-save race: two editors saving at once cannot both + // claim the same version number. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version + ON freight.support_document_versions (document_id, version); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document + ON freight.support_document_versions (document_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_document_versions;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts new file mode 100644 index 000000000..5220d3a6c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts @@ -0,0 +1,112 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Converts the HELP document from its original fixed-block shape + * (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form + * `sections[]` builder, where every block is a heading plus markdown plus + * attached media. + * + * Only rows still in the old shape are touched — detected by the presence of a + * `channels` key — so this is a no-op on any environment seeded after the + * change, and re-running it does nothing. + * + * The payload literal is inlined rather than imported from + * `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing + * forever, and that constant will keep moving. + * + * The rewrite also bumps `version` and writes a matching history row. The live + * row's version always having a matching entry in + * `support_document_versions` is the invariant the history list and rollback + * both depend on, and a silent payload swap would break it. + */ +const HELP_SECTIONS = [ + { + id: "help-walkthrough", + heading: "Portal walkthrough", + body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.", + media: [ + { + id: "help-walkthrough-video", + kind: "video", + src: "/assets/edr-portal-guide.webm", + caption: null, + }, + ], + }, + { + id: "help-chat", + heading: "Chat with our team", + body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)", + media: [], + }, + { + id: "help-contact", + heading: "Contact us", + body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.", + media: [], + }, + { + id: "help-topics", + heading: "Common topics", + body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", + media: [], + }, + { + id: "help-checklist", + heading: "What to include when you contact us", + body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.", + media: [], + }, +]; + +export class SupportHelpSections3360000000000 implements MigrationInterface { + name = "SupportHelpSections3360000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const rows: { id: string; version: number; payload: Record }[] = + await queryRunner.query(` + SELECT id, version, payload + FROM freight.support_documents + WHERE slug = 'HELP' AND payload ? 'channels' + `); + + for (const row of rows) { + const payload = { + title: row.payload.title ?? "Help & Support", + subtitle: + row.payload.subtitle ?? + "Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.", + sections: HELP_SECTIONS, + }; + const version = row.version + 1; + + await queryRunner.query( + `UPDATE freight.support_documents + SET payload = $1::jsonb, version = $2, updated_at = now() + WHERE id = $3`, + [JSON.stringify(payload), version, row.id], + ); + + await queryRunner.query( + `INSERT INTO freight.support_document_versions + (document_id, version, payload, actor_id, note) + VALUES ($1, $2, $3::jsonb, NULL, $4)`, + [ + row.id, + version, + JSON.stringify(payload), + "Converted help page to free-form sections", + ], + ); + } + } + + /** + * Not reversible: the old fixed blocks cannot be recovered from markdown + * sections an editor may since have rewritten. The version history holds the + * pre-conversion payload if it is ever genuinely needed. + */ + public async down(): Promise { + // no-op + } +} diff --git a/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts new file mode 100644 index 000000000..42acfc8f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Export cargo reaches a train two ways, and until now only one was modelled. + * + * DIRECT_TO_TRAIN — the customer's truck pulls alongside and the cargo goes + * straight onto the wagon. It never enters a warehouse, so no GRN is ever + * raised; the Carriage Acceptance Sheet is the only document handed over. + * + * WAREHOUSE — cargo is received into the warehouse, GRN'd, then loaded. This is + * the existing flow and stays gated on the GRN. + * + * NULL means WAREHOUSE, so existing rows keep today's behaviour with no backfill. + */ +export class BookingExportHandoverMode3370000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3370000000000-SupportHelpInlineMedia.ts b/apps/edr-freight-api/src/migrations/3370000000000-SupportHelpInlineMedia.ts new file mode 100644 index 000000000..3301056e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3370000000000-SupportHelpInlineMedia.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Folds each help section's `media[]` array into its markdown body. + * + * Attachments used to hang off the section as a separate list, rendered after + * the text — which meant an author could not put a picture next to the sentence + * it illustrates, and had two different places to manage media. They are now + * embedded with markdown's image syntax, and the renderer picks `` or + * `