Merge branch 'dev' into passenger/feat/iam

This commit is contained in:
Abubeker Yasin
2026-06-22 10:12:39 +03:00
1997 changed files with 435726 additions and 10897 deletions

583
.github/scripts/scan.js vendored Normal file
View File

@@ -0,0 +1,583 @@
/**
* PolinRider / Famous Chollima Supply-Chain Malware Scanner
*
* Detects the specific injection pattern used in the PolinRider campaign
* attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342).
*
* IOCs sourced from:
* - Direct analysis of the injected tailwind.config.js sample
* - Socket Security report (May 2026) on roberts/leads compromise
* - PolinRider technical analysis report (Trend Micro / safedep.io)
*
* Zero external dependencies — runs on any Node.js >= 14.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
// ─── Configuration ────────────────────────────────────────────────────────────
const CONFIG = {
// Maximum legitimate size for JS config files.
// Real tailwind/postcss/babel configs are rarely > 3 KB.
// Injected files jump to 58 KB instantly.
maxLegitConfigBytes: 3072,
// Minimum whitespace run on a single line that signals hidden payload.
// The campaign uses ~280510 spaces to push payload off-screen.
minSuspiciousInlineSpaces: 100,
// Files that are high-value injection targets for this campaign.
targetedFilenames: [
"tailwind.config.js",
"tailwind.config.ts",
"tailwind.config.cjs",
"tailwind.js",
"postcss.config.js",
"postcss.config.mjs",
"postcss.config.cjs",
"babel.config.js",
"babel.config.cjs",
"next.config.js",
"next.config.mjs",
"next.config.cjs",
"astro.config.mjs",
"astro.config.js",
"vite.config.js",
"vite.config.ts",
"webpack.config.js",
"webpack.mix.js",
],
// Files intentionally containing malware indicators for scanner logic/tests.
// These filenames are skipped before malware rules are evaluated.
ignoredFilenames: ["scan.js"],
// Filesystem paths that indicate active persistence mechanisms
persistenceArtifacts: [
"temp_auto_push.bat",
"temp_interactive_push.bat",
"branch_structure.json",
// Note: queue.bat and .plist are OS-level; checked separately
],
};
// ─── IOC Definitions ──────────────────────────────────────────────────────────
/**
* Each rule has:
* id unique rule identifier for reporting
* severity CRITICAL | HIGH | MEDIUM
* description human-readable explanation
* test(content, filePath, lines) returns array of match details or []
*/
const RULES = [
// ── Tier 1: Definitive campaign signatures ─────────────────────────────────
{
id: "POLINRIDER-001",
severity: "CRITICAL",
description:
"PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign",
test(content) {
const matches = [];
const re = /_\$_1e42/g;
let m;
while ((m = re.exec(content)) !== null) {
matches.push(`offset ${m.index}`);
}
return matches;
},
},
{
id: "POLINRIDER-002",
severity: "CRITICAL",
description:
"PolinRider campaign marker global['!'] assignment — used to route C2 traffic",
test(content) {
const matches = [];
const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g;
let m;
while ((m = re.exec(content)) !== null) {
const snippet = content
.slice(m.index, m.index + 40)
.replace(/\n/g, "\\n");
matches.push(`"${snippet}"`);
}
return matches;
},
},
{
id: "POLINRIDER-003",
severity: "CRITICAL",
description:
'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo',
test(content) {
return content.includes("rmcej%otb%") ? ["seed string found"] : [];
},
},
{
id: "POLINRIDER-004",
severity: "CRITICAL",
description:
"Known C2 IP addresses associated with PolinRider infrastructure",
test(content) {
const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"];
return knownC2.filter((ip) => content.includes(ip));
},
},
{
id: "POLINRIDER-005",
severity: "CRITICAL",
description:
"Known TRON blockchain wallet addresses used as dead-drop C2 resolvers",
test(content) {
const wallets = [
"TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
"TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
];
return wallets.filter((w) => content.includes(w));
},
},
{
id: "POLINRIDER-006",
severity: "CRITICAL",
description:
"Known Aptos blockchain addresses used as fallback dead-drop resolvers",
test(content) {
const addrs = [
"0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
"0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3",
];
return addrs.filter((a) => content.includes(a));
},
},
{
id: "POLINRIDER-007",
severity: "CRITICAL",
description:
"Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions",
test(content) {
const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"];
return keys.filter((k) => content.includes(k));
},
},
{
id: "POLINRIDER-008",
severity: "CRITICAL",
description:
"Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)",
test(content) {
const knownHashes = new Set([
"96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3",
"522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f",
]);
const hash = crypto.createHash("sha256").update(content).digest("hex");
return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : [];
},
},
// ── 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) {
const endpoints = [
"trongrid.io",
"aptoslabs.com",
"bsc-dataseed.binance.org",
"bsc-rpc.publicnode.com",
"eth_getTransactionByHash",
];
return endpoints.filter((e) => content.includes(e));
},
},
{
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"]
: [];
},
},
{
id: "POLINRIDER-011",
severity: "HIGH",
description:
"Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements",
test(content) {
const matches =
content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || [];
return matches.length >= 2
? [`Found ${matches.length} duplicate createRequire imports`]
: [];
},
},
{
id: "POLINRIDER-012",
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) {
const hits = [];
lines.forEach((line, i) => {
const spaceRun = line.match(/\s{100,}/);
if (spaceRun) {
hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`);
}
});
return hits;
},
},
{
id: "POLINRIDER-013",
severity: "HIGH",
description:
"Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 58 KB",
test(content, filePath) {
const bytes = Buffer.byteLength(content, "utf8");
const base = path.basename(filePath).toLowerCase();
const isTargeted = CONFIG.targetedFilenames.some(
(f) => f.toLowerCase() === base,
);
if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) {
return [
`${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`,
];
}
return [];
},
},
{
id: "POLINRIDER-014",
severity: "HIGH",
description:
"Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches",
test(_content, filePath) {
const base = path.basename(filePath);
return CONFIG.persistenceArtifacts.includes(base) ? [base] : [];
},
},
// ── Tier 3: Supporting behavioral indicators ───────────────────────────────
{
id: "POLINRIDER-015",
severity: "MEDIUM",
description:
"Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)",
test(content) {
const matches = [];
// Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928'
const re =
/global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g;
let m;
while ((m = re.exec(content)) !== null) {
matches.push(`marker value: "${m[1]}"`);
}
return matches;
},
},
{
id: "POLINRIDER-016",
severity: "MEDIUM",
description:
"sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant",
test(content) {
// sfL appears as a named function used to decode the larger payload blob
const occurrences = (content.match(/\bsfL\b/g) || []).length;
return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : [];
},
},
{
id: "POLINRIDER-017",
severity: "MEDIUM",
description:
"global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions",
test(content) {
const hits = [];
if (/global\s*\[.*\]\s*=\s*require/.test(content))
hits.push("global[x] = require");
if (/global\s*\[.*module.*\]\s*=\s*module/.test(content))
hits.push("global[x] = module");
return hits;
},
},
];
// ─── Scanner Engine ────────────────────────────────────────────────────────────
function scanFile(filePath) {
if (shouldIgnoreFile(filePath)) {
return { filePath, findings: [], skipped: true };
}
let content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch (err) {
return { filePath, error: err.message, findings: [] };
}
const lines = content.split("\n");
const findings = [];
for (const rule of RULES) {
let matches;
try {
matches = rule.test(content, filePath, lines);
} catch (err) {
matches = [`[rule error: ${err.message}]`];
}
if (matches && matches.length > 0) {
findings.push({
id: rule.id,
severity: rule.severity,
description: rule.description,
matches,
});
}
}
return { filePath, findings };
}
function shouldIgnoreFile(filePath) {
const base = path.basename(filePath).toLowerCase();
return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base);
}
function walkDir(dir, results = []) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(full, results);
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
const ext = path.extname(entry.name).toLowerCase();
const base = entry.name.toLowerCase();
// Scan all JS/TS config files + any file matching a targeted name
const isTargetedName = CONFIG.targetedFilenames.some(
(f) => f.toLowerCase() === base,
);
const isPersistenceArtifact = CONFIG.persistenceArtifacts.some(
(f) => f.toLowerCase() === base,
);
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
ext,
);
if (isTargetedName || isPersistenceArtifact || isJsLike) {
results.push(full);
}
}
}
return results;
}
// ─── Reporting ─────────────────────────────────────────────────────────────────
const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
const ANSI = {
reset: "\x1b[0m",
bold: "\x1b[1m",
red: "\x1b[31m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
green: "\x1b[32m",
dim: "\x1b[2m",
};
function colorSeverity(sev) {
if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`;
if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`;
return `${ANSI.cyan}${sev}${ANSI.reset}`;
}
function printReport(allResults, { json = false, outputFile = null } = {}) {
const infected = allResults.filter(
(r) => r.findings && r.findings.length > 0,
);
const errors = allResults.filter((r) => r.error);
if (json) {
const report = {
scannedAt: new Date().toISOString(),
totalFilesScanned: allResults.length,
infectedFiles: infected.length,
results: infected,
errors,
};
const out = JSON.stringify(report, null, 2);
if (outputFile) {
fs.writeFileSync(outputFile, out);
console.log(`JSON report written to: ${outputFile}`);
} else {
console.log(out);
}
return infected.length > 0;
}
// Human-readable output
console.log(
`\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`,
);
console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`);
console.log(
`╚══════════════════════════════════════════════════════════╝${ANSI.reset}`,
);
console.log(
`${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`,
);
if (infected.length === 0) {
console.log(
`${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`,
);
} else {
console.log(
`${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`,
);
for (const result of infected) {
// Sort findings by severity descending
const sorted = [...result.findings].sort(
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
);
const topSev = sorted[0].severity;
console.log(
` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`,
);
for (const f of sorted) {
console.log(
` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)}${f.description}`,
);
for (const m of f.matches) {
console.log(`${m}`);
}
}
console.log();
}
console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`);
console.log(
` 1. Immediately isolate the affected machine from the network.`,
);
console.log(
` 2. Do NOT run npm install, npm build, or any script on this repo.`,
);
console.log(
` 3. Check for running node.exe / node processes with obfuscated args.`,
);
console.log(
` 4. Remove all code after the legitimate config closing block.`,
);
console.log(
` 5. Remove duplicate 'import { createRequire }' lines at file top.`,
);
console.log(
` 6. Recover the git repository from a clean local clone (see docs).`,
);
console.log(
` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`,
);
console.log(
` 8. See full remediation guide in the attached incident report.\n`,
);
}
if (errors.length > 0) {
console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`);
for (const e of errors) {
console.log(` ${e.filePath}: ${e.error}`);
}
console.log();
}
return infected.length > 0;
}
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
const jsonFlag = args.includes("--json");
const outputFileIdx = args.indexOf("--output");
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;
// Positional args after flags are scan targets
const targets = args.filter(
(a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output",
);
if (targets.length === 0) {
console.error(
"Usage: scan.js [--json] [--output report.json] <path> [path...]",
);
console.error(
" path can be a file or directory (directories are walked recursively)",
);
process.exit(1);
}
const filesToScan = [];
for (const target of targets) {
if (!fs.existsSync(target)) {
console.error(`Path not found: ${target}`);
process.exit(1);
}
const stat = fs.statSync(target);
if (stat.isDirectory()) {
const found = walkDir(target);
filesToScan.push(...found);
} else {
filesToScan.push(target);
}
}
// Deduplicate
const unique = [...new Set(filesToScan)];
const allResults = unique.map(scanFile);
const infected = printReport(allResults, { json: jsonFlag, outputFile });
// Exit code 1 if any infection found — used by CI to block deployments
process.exit(infected ? 1 : 0);
}
main();

View File

@@ -1,66 +1,131 @@
name: Deploy Stacks
on:
push:
branches:
- main
- dev
- staging
paths:
- "apps/edr-freight-api/**"
- "apps/edr-freight-web/**"
- "apps/edr-passenger-api/**"
- "apps/edr-passenger-web/**"
- "packages/**"
- "infrastructure/docker/Dockerfile.web"
- "infrastructure/nginx/**"
- "docker-compose.yaml"
- "pnpm-lock.yaml"
- "scripts/deploy/**"
- ".github/workflows/deploy.yml"
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
detect-changes:
name: Detect changed services
runs-on: self-hosted
outputs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Determine changed services
id: filter
run: |
set -euo pipefail
ALL_SERVICES=(
"freight-api"
"freight-portal"
"freight-backoffice"
"passenger-api"
"passenger-portal"
"passenger-backoffice"
"payment-api"
)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only HEAD~1 HEAD)
echo "=== Changed files ==="
echo "$CHANGED"
echo "====================="
SERVICES=()
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
if [ -z "$DEPLOYABLE" ]; then
echo "Only non-deployable files changed. Skipping deploy."
echo "matrix=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then
echo "Global file(s) changed — deploying all services."
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
if [ ${#SERVICES[@]} -eq 0 ]; then
echo "No deployable service changes detected."
echo "matrix=[]" >> "$GITHUB_OUTPUT"
else
echo "Services to deploy: ${SERVICES[*]}"
JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
fi
deploy:
name: Deploy ${{ matrix.service }}
needs: detect-changes
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
include:
- project: edr-freight
build_env_file: freight-web.build.env
service: freight-api
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-portal
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-backoffice
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-api
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-portal
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-backoffice
service: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
env:
PROJECT: ${{ matrix.project }}
BRANCH: ${{ github.ref_name }}
DEPLOY_USER: tria
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve project and build env file
run: |
case "${{ matrix.service }}" in
freight-api|freight-portal|freight-backoffice)
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
;;
passenger-api|passenger-portal|passenger-backoffice)
echo "PROJECT=edr-passenger" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=passenger-web.build.env" >> "$GITHUB_ENV"
;;
payment-api)
echo "PROJECT=edr-payment" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=payment-web.build.env" >> "$GITHUB_ENV"
;;
*)
echo "Unknown service: ${{ matrix.service }}" && exit 1
;;
esac
- name: Sync environment from server
run: |
chmod +x scripts/deploy/*.sh
@@ -80,12 +145,12 @@ jobs:
- name: Build ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
- name: Deploy ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
- name: Remove npm credentials from workspace
if: always()

9
.gitignore vendored
View File

@@ -7,6 +7,7 @@ node_modules/
coverage/
*.tsbuildinfo
**/*.tsbuildinfo
**/vite.config.ts.timestamp-*.mjs
# env
.env
@@ -23,6 +24,8 @@ coverage/
.idea/
.vscode/
.npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
# emacs cache files
*~
\#*\#
.\#*

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "user-management"]
path = user-management
url = git@github.com:Tria-plc/iamui.git
[submodule "apps/edr-freight-web/backoffice/user-management"]
path = apps/edr-freight-web/backoffice/user-management
url = git@github.com:Tria-plc/iamui.git

11
.npmrc
View File

@@ -1,11 +0,0 @@
# Increase fetch timeouts for network resilience
fetch-timeout=60000
fetch-retry-mintimeout=20000
fetch-retry-maxtimeout=120000
# GitHub Packages configuration for @tria-plc scope
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN}
# Default registry for other packages
registry=https://registry.npmjs.org/

View File

@@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
@@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
- `edr-freight-web/portal`: 5173
- `edr-freight-web/backoffice`: 5183
- `edr-passenger-api`: 3002
- `edr-payment-api`: 3003
- `edr-passenger-web/portal`: 5174
- `edr-passenger-web/backoffice`: 5184
@@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
## Adding a new module to a NestJS app

View File

@@ -61,15 +61,16 @@ The sync script validates this and fails if missing.
### Build env files (optional)
Used for build-time variables (example: Vite API URLs), with `export` syntax:
Used for additional build-time variables (example: Vite API URL for freight web), with `export` syntax:
```bash
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
export PASSENGER_VITE_API_URL=https://passenger-api.example.com
```
These are injected into `GITHUB_ENV` during workflow execution.
> **Passenger web:** `NEXT_PUBLIC_API_URL` does **not** need a separate build env file. Place it directly in the service runtime env file (`passenger-portal.env` / `passenger-backoffice.env`) and the sync script will forward it to the build automatically.
## Docker Compose Port Mapping
`docker-compose.yaml` uses per-service env variables for host/container port mappings:
@@ -83,6 +84,51 @@ These are injected into `GITHUB_ENV` during workflow execution.
`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`.
## Passenger Web Docker Configuration
The passenger web apps (portal and backoffice) are deployed as **Next.js applications** using a dedicated Dockerfile:
- Dockerfile: `infrastructure/docker/Dockerfile.passenger-web`
- Apps: `apps/edr-passenger-web/portal` and `apps/edr-passenger-web/backoffice`
### Key differences from freight-web
| Aspect | Freight Web | Passenger Web |
| --- | --- | --- |
| Framework | Vite (SPA) | Next.js (SSR/SSG) |
| Deployment | Static export + nginx | Node.js server |
| Dockerfile | `Dockerfile.web` | `Dockerfile.passenger-web` |
| Final port (container) | 80 (nginx) | driven by `PORT` in service `.env` |
| Build arg | `TURBO_FILTER` | `APP_PACKAGE` + `APP_PATH` + `NEXT_PUBLIC_API_URL` |
### Build arguments
The Dockerfile accepts the following build args:
- `APP_PACKAGE`: Turbo package filter (e.g., `@edr/passenger-portal`)
- `APP_PATH`: App directory path (e.g., `apps/edr-passenger-web/portal`)
- `NEXT_PUBLIC_API_URL`: API URL visible to browser — sourced from `NEXT_PUBLIC_API_URL` in the service `.env` file
### Port mapping
Both host and container ports are driven by `PORT` in the service env file. The sync script reads `PORT`, exports `PASSENGER_PORTAL_PORT` / `PASSENGER_BACKOFFICE_PORT` to `GITHUB_ENV`, and `docker-compose.yaml` uses those variables for both sides of the mapping:
```
${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}
```
This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
### Runtime
The final image runs:
```bash
npx next start
```
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
## GitHub Actions Deployment Flow
Workflow file: `.github/workflows/deploy.yml`

1002
ITMLS_DB_Design.md Normal file

File diff suppressed because it is too large Load Diff

257
README.md
View File

@@ -93,6 +93,189 @@ cd edr-platform
```
### 2. Install Dependencies
# EDR Platform
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
---
## Tech Stack
### Backend
| Layer | Tech |
| ---------------- | ----------------------------------------------- |
| Runtime | Node.js ≥ 20 |
| Framework | NestJS 11 (modular architecture) |
| Language | TypeScript 5 (strict mode, project-wide) |
| ORM | TypeORM 0.3 (UUID PKs, soft deletes, `snake_case` columns) |
| Database | PostgreSQL 16 (one DB per domain) |
| Validation | class-validator + class-transformer |
| API docs | Swagger via `@nestjs/swagger` |
| Messaging | `@nestjs/microservices` (inter-service ready) |
| Testing | Jest + Supertest |
### Frontend
| Layer | Tech |
| ---------------- | ----------------------------------------------- |
| Framework | React 18 + Vite 5 |
| Language | TypeScript 5 (strict) |
| Routing | React Router v6 |
| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) + `tailwind-merge` + `class-variance-authority` |
| UI primitives | Radix UI (meta `radix-ui` package, shadcn-style components) |
| Icons | lucide-react |
| State / Data | Zustand (client state) · TanStack Query (server state) |
| HTTP | Axios |
| Auth UI | `@tria-plc/iamui-common` (external IAM) |
### Shared Packages
| Package | Purpose |
| ---------------------- | -------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | NestJS decorators, filters, interceptors, pipes, `BaseEntity`, `BaseRepository` |
| `@edr/ui-common` | Shared React components (`DashboardLayout`, `Sidebar`, `Button`, `Modal`, etc.) and theme tokens |
| `@edr/eslint-config` | Shared ESLint configs (base / nestjs / react) |
| `@edr/tsconfig` | Shared TypeScript configs |
| `@edr/prettier-config` | Shared Prettier configuration |
### Tooling
- **pnpm 9** — workspace package manager (sole supported PM)
- **Turborepo 2** — task orchestrator with caching
- **Husky + lint-staged + commitlint** — pre-commit ESLint/Prettier and conventional-commit enforcement
- **Docker Compose** — local Postgres instances + production stack (see `infrastructure/`)
- **Nginx** — reverse proxy / static asset server in production
### Planned Integrations
- **MinIO** — S3-compatible object storage for documents (object keys already shaped under `edr-freight/{linkedType}/{ref}/{filename}`)
---
## Architecture
### High-level layout
```
┌──────────────────────────────────────────────────────────────────┐
│ EDR Platform (monorepo) │
├──────────────────────┬───────────────────────────────────────────┤
│ Freight domain │ Passenger domain │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ freight-portal │ │ │ passenger- │ │
│ │ (React) │ │ │ portal (React)│ │
│ ├────────────────┤ │ ├────────────────┤ │
│ │ freight- │ │ │ passenger- │ │
│ │ backoffice │ │ │ backoffice │ │
│ └───────┬────────┘ │ └───────┬────────┘ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ freight-api │ │ │ passenger-api │ │
│ │ (NestJS) │ │ │ (NestJS) │ │
│ └───────┬────────┘ │ └───────┬────────┘ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ postgres- │ │ │ postgres- │ │
│ │ freight │ │ │ passenger │ │
│ └────────────────┘ │ └────────────────┘ │
└──────────────────────┴───────────────────────────────────────────┘
Shared (workspace) packages
@edr/types · @edr/api-common · @edr/ui-common · @edr/{eslint,tsconfig,prettier}-config
```
### Domain isolation
- **One database per domain.** `postgres-freight` (port 5433, db `edr_freight`) and `postgres-passenger` (port 5434, db `edr_passenger`). No cross-database joins. Cross-domain data flows only through API calls or message queues.
- **Each domain owns its data model.** Freight bookings/consignments/shipments/trains/invoices/documents live only in the freight DB; passenger journeys/tickets live only in the passenger DB.
### NestJS module pattern (per feature)
```
modules/<feature>/
entities/<feature>.entity.ts // extends BaseEntity (UUID, timestamps, soft delete)
dto/<verb>-<feature>.dto.ts // class-validator DTOs
<feature>.module.ts // wires controller + service + repository
<feature>.controller.ts // HTTP layer only — no business logic
<feature>.service.ts // business logic
<feature>.repository.ts // extends BaseRepository<Entity>; services inject this, NEVER `Repository<T>` directly
```
Conventions enforced across the codebase:
- All entities have UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- All entities inherit `createdAt` / `updatedAt` / `deletedAt` from `BaseEntity` (soft delete).
- DB columns use `snake_case` via `@Column({ name: '...' })`; TS properties stay `camelCase`.
- **No `synchronize: true`** in production — schema changes go through TypeORM migrations.
- ESLint + Prettier run on pre-commit via Husky + lint-staged.
- Conventional-commits enforced via commitlint.
### Frontend application structure
```
apps/edr-freight-web/portal/src/
App.tsx // routes + sidebar definition
main.tsx // React Router + QueryClient providers
components/
Breadcrumbs.tsx // shared local UI
ui/ // shadcn-style primitives (Button, Input, Dialog, Label, Textarea)
pages/
customers/ // CustomersPage + CustomerDetailPage + NewCustomerPage (dialog)
bookings/ // ... + multimodal Transport Legs editor
consignments/
tracking/ // Shipment grid + table view toggle
trains/ // Fleet roster
billing/ // Invoices
documents/ // MinIO-shaped document library
hooks/ // TanStack Query hooks (per feature)
services/ // Axios clients (per feature)
store/ // Zustand stores
lib/ // utilities (`cn` helper, formatters)
```
Each feature folder typically contains: `*Page.tsx` (list), `*DetailPage.tsx`, `New*Page.tsx` (create/edit dialog with `mode: "create" | "edit"`), `Delete*Dialog.tsx`, and a `*.mock.ts` seed file used by the current mock UI.
### Shared layout (`@edr/ui-common`)
`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps:
- **Sidebar** — brand-tinted, icon-led navigation. Main brand color: `#10B981` (`rgb(16, 185, 129)` — emerald-500). Icon containers use the filled style: brand-color background with a white icon.
- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`).
### Auth integration (planned)
Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamapi-common` package. **Do not** implement login/JWT/password logic in this repo. Use placeholder TODO comments next to controllers and `@CurrentUser` decorators (in `@edr/api-common`) until the integration ships.
---
## Apps & Ports
| App | Package name | Purpose | Port |
| ------------------------------ | --------------------------- | ---------------------------------------- | ---- |
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customers | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passengers | 3002 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customers | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger employees | 5184 |
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds independent `portal/` and `backoffice/` workspace packages declared in `pnpm-workspace.yaml`.
---
## Getting Started
### Prerequisites
- Node.js ≥ 20
- pnpm 9 (`corepack enable && corepack prepare pnpm@9.12.0 --activate`)
- Docker (for local Postgres)
### Install
```bash
pnpm install
```
@@ -255,7 +438,7 @@ The API uses two authentication schemes:
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration |
| **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration |
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
@@ -765,3 +948,75 @@ For technical support or questions:
---
**Built with ❤️ for Ethio-Djibouti Railway**
### Start local databases
```bash
docker compose -f infrastructure/docker/docker-compose.db.dev.yml up -d
```
### Run apps
```bash
pnpm dev # every app
pnpm dev:freight # freight API + portal + backoffice
pnpm dev:passenger # passenger API + portal + backoffice
```
### Common scripts
| Script | Description |
| ------------------- | ------------------------------------ |
| `pnpm install` | Install workspace dependencies |
| `pnpm dev` | Run every app in watch mode |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests |
| `pnpm lint` | Lint everything |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format with Prettier |
---
## Repository Layout
```
.
├── apps/
│ ├── edr-freight-api/ NestJS — freight backend
│ ├── edr-freight-web/
│ │ ├── portal/ React — freight customer portal
│ │ └── backoffice/ React — freight back-office
│ ├── edr-passenger-api/ NestJS — passenger backend
│ └── edr-passenger-web/
│ ├── portal/ React — passenger customer portal
│ └── backoffice/ React — passenger back-office
├── packages/
│ ├── api-common/ Shared NestJS utilities + BaseEntity/Repository
│ ├── types/ Shared TS types/enums
│ ├── ui-common/ Shared React components + theme
│ └── config/
│ ├── eslint/ @edr/eslint-config
│ ├── tsconfig/ @edr/tsconfig
│ └── prettier/ @edr/prettier-config
├── infrastructure/
│ ├── docker/ docker-compose files (db.dev / dev / prod)
│ └── nginx/ Production nginx config
├── CLAUDE.md Developer guide for AI-assisted work
├── turbo.json Turborepo task pipeline
├── pnpm-workspace.yaml Workspace manifest
└── README.md
```
---
## Standards (recap)
- **TypeScript strict mode** is enabled in every package.
- **pnpm only** — never run `npm install` or `yarn`.
- **Conventional commits** — enforced via commitlint on every commit.
- **NestJS 4-layer pattern** — `module → controller → service → repository`.
- **Repository injection** — services inject the custom `*Repository` class, not `Repository<T>`.
- **Controllers are thin** — no business logic; delegate to services.
- **Migrations only** — never enable TypeORM `synchronize` in production.
- **One DB per domain** — no cross-database joins.
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.

0
WagonForm.tsx Normal file
View File

View File

@@ -5,3 +5,50 @@ DB_PORT=5433
DB_USER=postgres
DB_PASSWORD=
DB_NAME=edr_freight
# Telebirr payment gateway (freight merchant credentials)
TELEBIRR_BASE_URL=
TELEBIRR_WEB_BASE_URL=
TELEBIRR_FABRIC_APP_ID=
TELEBIRR_APP_SECRET=
TELEBIRR_MERCHANT_APP_ID=
TELEBIRR_MERCHANT_CODE=
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
TELEBIRR_RETURN_URL=
TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# Portal pages the payment provider redirects the browser to after payment.
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=
JWT_REFRESH_TOKEN_SECRET=
JWT_EXPIRES_IN=3600
# JWT expiry for @tria-plc/api-common token utils (jsonwebtoken timespan format)
JWT_ACCESS_TOKEN_EXPIRES=1h
JWT_REFRESH_TOKEN_EXPIRES=7d
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE=
DEFAULT_PASSWORD=password@tria
# Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
# MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

View File

@@ -3,6 +3,21 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": false,
"assets": [
{
"include": "migrations/**/*",
"outDir": "dist"
},
{
"include": "contracts/templates/**/*",
"watchAssets": true
},
{
"include": "modules/payment/templates/**/*",
"watchAssets": true
}
],
"watchAssets": true
}
}

View File

@@ -4,44 +4,68 @@
"private": true,
"description": "EDR Freight Management API",
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
"dev": "nest start --watch",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint src",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit"
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
},
"dependencies": {
"@tria-plc/api-common": "^0.1.0",
"@tria-plc/iamapi-common": "^0.1.0",
"@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "^1.4.3",
"@tria-plc/iamapi-common": "^0.6.6",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"handlebars": "^4.7.9",
"minio": "7.1.3",
"pg": "^8.13.0",
"puppeteer": "^24.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
"typeorm": "^0.3.30"
},
"devDependencies": {
"@edr/api-common": "workspace:*",
"@edr/types": "workspace:*",
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/amqplib": "^0.10.8",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"supertest": "^7.0.0",

206
apps/edr-freight-api/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,206 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@edr/api-common':
specifier: workspace:*
version: link:../../packages/api-common
'@edr/types':
specifier: workspace:*
version: link:../../packages/types
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types':
specifier: ^2.1.1
version: 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.1
version: 7.8.2
devDependencies:
'@edr/eslint-config':
specifier: workspace:*
version: link:../../packages/config/eslint-config
'@edr/tsconfig':
specifier: workspace:*
version: link:../../packages/config/tsconfig
packages:
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@nestjs/common@11.1.24':
resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==}
peerDependencies:
class-transformer: '>=0.4.1'
class-validator: '>=0.13.2'
reflect-metadata: ^0.1.12 || ^0.2.0
rxjs: ^7.1.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/mapped-types@2.1.1':
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
file-type@21.3.4:
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
engines: {node: '>=20'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
iterare@1.2.1:
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
engines: {node: '>=6'}
load-esm@1.0.3:
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
engines: {node: '>=13.2.0'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
token-types@6.1.2:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
uid@2.0.2:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'}
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
snapshots:
'@borewit/text-codec@0.2.2': {}
'@lukeed/csprng@1.1.0': {}
'@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
file-type: 21.3.4
iterare: 1.2.1
load-esm: 1.0.3
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
uid: 2.0.2
transitivePeerDependencies:
- supports-color
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
'@tokenizer/token@0.3.0': {}
debug@4.4.3:
dependencies:
ms: 2.1.3
file-type@21.3.4:
dependencies:
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.5
token-types: 6.1.2
uint8array-extras: 1.5.0
transitivePeerDependencies:
- supports-color
ieee754@1.2.1: {}
iterare@1.2.1: {}
load-esm@1.0.3: {}
ms@2.1.3: {}
reflect-metadata@0.2.2: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
strtok3@10.3.5:
dependencies:
'@tokenizer/token': 0.3.0
token-types@6.1.2:
dependencies:
'@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
tslib@2.8.1: {}
uid@2.0.2:
dependencies:
'@lukeed/csprng': 1.1.0
uint8array-extras@1.5.0: {}

View File

@@ -1,36 +1,157 @@
import { Module } from "@nestjs/common";
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { SignaturesModule } from "./modules/signatures/signatures.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
import { TrainsModule } from "./modules/trains/trains.module";
import { CustomersModule } from "./modules/customers/customers.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from "./modules/otp/otp.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,
EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig],
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
// EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
dataSourceFactory: async (options) => {
if (!options) {
throw new Error("Missing TypeORM DataSource options");
}
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
return dataSource.initialize();
},
}),
SharedAuthModule,
IamModule.forRoot({
applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
TrainsModule,
CustomersModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
TrainSchedulingModule,
SchedulingRescheduleModule,
CompaniesModule,
TrackingModule,
BillingModule,
NotificationsModule,
FileUploadSettingsModule,
DropdownSettingsModule,
OtpModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
PaymentModule,
//New Modules
TrainsModule,
WagonsModule,
ContainersModule,
CargoesModule,
RoutesModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
],
})
export class AppModule {}
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
async onApplicationBootstrap() {
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
// FileUploadSettingsSeeder) are intentionally disabled — they stay
// registered as providers but are not run. Re-inject + call .run() to enable.
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
}
}

View File

@@ -0,0 +1,30 @@
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 { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightPermissionGuard(
Array.isArray(permission) ? permission : [permission],
),
),
);
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

@@ -0,0 +1,21 @@
import { deriveTradeDirection } from './derive-trade-direction.util';
describe('deriveTradeDirection', () => {
it('returns IMPORT when origin is Djibouti', () => {
expect(deriveTradeDirection({ country: 'Djibouti' }, { country: 'Ethiopia' })).toBe(
'IMPORT',
);
});
it('returns EXPORT when destination is Djibouti and origin is not', () => {
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Djibouti' })).toBe(
'EXPORT',
);
});
it('returns DOMESTIC for intra-Ethiopia routes', () => {
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' })).toBe(
'DOMESTIC',
);
});
});

View File

@@ -0,0 +1,20 @@
import type { ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null };
/** Derive booking/schedule trade direction from origin and destination yard countries. */
export function deriveTradeDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
if (originCountry === 'Djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';
}

View File

@@ -0,0 +1,38 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Type,
UnauthorizedException,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission } from './freight-permission.util';
export function FreightPermissionGuard(
permissions: string[],
): Type<CanActivate> {
@Injectable()
class FreightPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!permissions?.length) return true;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (permissions.some((p) => hasFreightPermission(user, p))) {
return true;
}
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(', ')}`,
);
}
}
return FreightPermissionsGuard;
}

View File

@@ -0,0 +1,109 @@
import { ForbiddenException } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
const SUPER_ADMIN_ROLE = 'super_admin';
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
type PermissionLike = { key?: string };
type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
position?: { permissions?: PermissionLike[] };
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
positions?: { permissions?: PermissionLike[] }[];
}[]
| null;
};
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
if (!user?.roles?.length) return false;
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
}
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
if (!user?.roles?.length) return false;
return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE);
}
export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean {
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
const employee = user.employee;
if (!employee) {
return [...keys];
}
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
return [...keys];
}
export function hasFreightPermission(
user: MeLikeUser | null | undefined,
permissionKey: string,
): boolean {
if (!user) return false;
if (isSuperAdmin(user)) return true;
return collectPermissionKeys(user).includes(permissionKey);
}
export function assertFreightPermission(
user: TCurrentUser | MeLikeUser | null | undefined,
permissionKey: string,
): void {
if (hasFreightPermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): void {
if (isFreightApprovalAdmin(user)) return;
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
if (!perm) {
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
}
assertFreightPermission(user, perm);
}

View File

@@ -0,0 +1,51 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from "@nestjs/common";
import { timingSafeEqual } from "node:crypto";
import { Request } from "express";
/**
* Shared-secret guard for endpoints only the payment microservice may call
* (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN
* the payment service uses on its own internal surface.
*/
@Injectable()
export class ServiceAuthGuard implements CanActivate {
private readonly logger = new Logger(ServiceAuthGuard.name);
private readonly token = process.env.SERVICE_AUTH_TOKEN ?? "";
private warned = false;
constructor() {
if (!this.token && process.env.NODE_ENV === "production") {
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
}
}
canActivate(context: ExecutionContext): boolean {
if (!this.token) {
if (!this.warned) {
this.logger.warn(
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
);
this.warned = true;
}
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const header = request.headers["x-service-token"];
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
const presented = (Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
const expected = Buffer.from(this.token);
const actual = Buffer.from(presented);
const valid =
expected.length === actual.length && timingSafeEqual(expected, actual);
if (!valid) throw new UnauthorizedException("Invalid service token");
return true;
}
}

View File

@@ -0,0 +1,12 @@
import { UnauthorizedException } from '@nestjs/common';
export type AuthUserPayload = { id?: string; sub?: string } | null | undefined;
/** Resolve IAM user id from JWT payload attached by JwtGuard. */
export function resolveAuthUserId(user: AuthUserPayload): string {
const id = user?.id ?? user?.sub;
if (!id) {
throw new UnauthorizedException('Authentication required');
}
return id;
}

View File

@@ -0,0 +1,18 @@
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 {
FREIGHT_PERMS,
type RuleEngineResourceSlug,
} from '../seed/freight-permissions.registry';
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
);

View File

@@ -0,0 +1,15 @@
/**
* Derives a stable, uppercase, underscore-separated code from a human-readable name.
*
* Examples:
* "Hazard Surcharge" → "HAZARD_SURCHARGE"
* "20ft Dry Container" → "20FT_DRY_CONTAINER"
* "Kality Yard (ET)" → "KALITY_YARD_ET"
*/
export function generateCode(name: string): string {
return name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}

View File

@@ -1,7 +1,31 @@
import { registerAs } from "@nestjs/config";
const numberFromEnv = (key: string, fallback: number): number => {
const value = Number(process.env[key]);
return Number.isFinite(value) && value > 0 ? value : fallback;
};
export default registerAs("app", () => ({
env: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "3001", 10),
apiPrefix: "api",
trainScheduling: {
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
},
cbeExchange: {
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
scrapeUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET",
/** @deprecated use scrapeUrl — kept for backward-compatible config reads */
apiUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET",
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
},
}));

View File

@@ -1,19 +1,123 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
DefaultUnit,
EmployeePosition,
Employee,
OrganizationConfiguration,
GlobalOrganizationConfiguration,
OrganizationType,
Organization,
PositionConfiguration,
PositionPermission,
PositionTypeConfiguration,
PositionTypePermission,
PositionType,
Position,
Project,
GlobalUnitConfiguration,
Unit,
EmployeeSignature,
EmployeeStamp,
RecordFooter,
RecordHeader,
Seal,
AccountConfiguration,
Application,
DocumentaryRequirement,
Permission,
RolePermission,
Role,
Session,
UserCredential,
UserDocument,
UserRole,
UserVerification,
User,
Notification,
NotificationEvent,
NotificationPlaceholder,
NotificationReceiverField,
NotificationReceiver,
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
const iamEntities = [
DefaultPosition,
DefaultUnit,
EmployeePosition,
Employee,
OrganizationConfiguration,
GlobalOrganizationConfiguration,
OrganizationSetting,
OrganizationType,
Organization,
PositionConfiguration,
PositionPermission,
PositionTypeConfiguration,
PositionTypePermission,
PositionType,
Position,
Project,
GlobalUnitConfiguration,
Unit,
EmployeeSignature,
EmployeeStamp,
RecordFooter,
RecordHeader,
Seal,
AccountConfiguration,
Application,
DocumentaryRequirement,
Permission,
RolePermission,
Role,
Session,
UserCredential,
UserDocument,
UserRole,
UserVerification,
User,
Notification,
NotificationEvent,
NotificationPlaceholder,
NotificationReceiverField,
NotificationReceiver,
NotificationTemplate,
];
const iamMigrationsGlob = join(
dirname(require.resolve("@tria-plc/iamapi-common/package.json")),
"dist/db/migrations/*.js",
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export default registerAs("database", (): TypeOrmModuleOptions => {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
entities: [__dirname + "/../**/*.entity.{ts,js}"],
migrations: [__dirname + "/../../migrations/*.{ts,js}"],
// Never enable synchronize in production. Use migrations.
synchronize: process.env.NODE_ENV === "development",
schema: "public",
extra: {
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
},
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
// IAM schema + tables must be created before freight migrations
iamMigrationsGlob,
freightMigrationsGlob,
],
migrationsRun: true,
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging: process.env.NODE_ENV === "development",
}),
);
};
});

View File

@@ -0,0 +1,10 @@
import { registerAs } from "@nestjs/config";
export default registerAs("dmoney", () => ({
baseUrl: process.env.DMONEY_BASE_URL ?? "",
appId: process.env.DMONEY_APP_ID ?? "",
appSecret: process.env.DMONEY_APP_SECRET ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? ""
}));

View File

@@ -0,0 +1,50 @@
import { DataSource, DataSourceOptions } from "typeorm";
/** Schemas required before TypeORM migrations and entity access. */
export const APPLICATION_SCHEMAS = [
"public",
"iam",
"freight",
"audit",
] as const;
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
/**
* TypeORM creates the migrations table before any migration runs. If `public` was
* dropped, current_schema() is null and CREATE TABLE migrations fails.
* IAM/freight migrations assume their schemas already exist.
*/
export async function ensurePostgresSchemas(
options: DataSourceOptions,
): Promise<void> {
const bootstrap = new DataSource({
...options,
entities: [],
migrations: [],
migrationsRun: false,
synchronize: false,
});
await bootstrap.initialize();
for (const schema of APPLICATION_SCHEMAS) {
if (schema === "public") {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
await bootstrap.query(`GRANT CREATE ON SCHEMA public TO public`);
} else {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
await bootstrap.query(
`GRANT CREATE ON SCHEMA "${schema}" TO public`,
);
}
}
await bootstrap.query(
`SET search_path TO ${APPLICATION_SEARCH_PATH}`,
);
await bootstrap.destroy();
}

View File

@@ -0,0 +1,11 @@
import { registerAs } from '@nestjs/config';
/**
* RabbitMQ connection for the payment-event consumer (payment microservice -> freight).
* Points at the dedicated `payment` vhost on the shared broker.
*/
export default registerAs('rabbitmq', () => ({
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
/** Max unacked payment events held by this consumer at once. */
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
}));

View File

@@ -0,0 +1,16 @@
import { registerAs } from "@nestjs/config";
export default registerAs("telebirr", () => ({
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
}));

View File

@@ -0,0 +1,224 @@
import type {
Article1Clause,
ContractClausePack,
ContractDirection,
ContractFreight,
ContractServiceScope,
} from './contract-template.types';
const STANDARD_CONTRACT_DOCUMENTS = [
'Amendments (if any)',
'This Contract Agreement',
'Final Minutes of Negotiation (if any)',
];
const PAYMENT_OBLIGATION =
'Pay 100% transportation fees in advance per train set in accordance with Article 5.';
const HAZARDOUS_OBLIGATION =
'Notify EDR 48 hours in advance for hazardous or valuable cargo.';
function clonePack(pack: ContractClausePack): ContractClausePack {
return {
article1: {
objective: pack.article1.objective,
scope: [...pack.article1.scope],
},
clientObligations: [...pack.clientObligations],
providerObligations: [...pack.providerObligations],
contractDocuments: [...pack.contractDocuments],
};
}
function applyForwardingOverlay(
pack: ContractClausePack,
service: ContractServiceScope,
): ContractClausePack {
if (service !== 'FORWARDING') return pack;
const next = clonePack(pack);
next.article1.scope.push(
'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.',
);
next.providerObligations.push(
'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.',
);
return next;
}
function buildImportContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.',
scope: [
'Railway transport service on the agreed import corridor.',
'Cargo handling at Galaan Multipurpose port (GMP) where applicable.',
],
},
clientObligations: [
'Provide shipment instructions to EDR for container movements on the agreed corridor.',
'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.',
'Submit required documents to Djibouti Nagad station at least 24 hours before loading.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
'Provide safe transportation and deliver within agreed timelines when documents are complete.',
'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildImportBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.',
scope: [
'Railway bulk transport service on the agreed import corridor.',
'Loading and unloading coordination at designated terminals per EDR operational rules.',
],
},
clientObligations: [
'Provide accurate commodity description, weight, and shipment instructions for each train movement.',
'Ensure cargo is prepared and available at origin per the agreed loading window.',
'Submit required customs and operational documents at least 24 hours before loading where applicable.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildExportContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.',
scope: [
'Railway export transport service on the agreed corridor.',
'Terminal coordination at origin yards for export dispatch where applicable.',
],
},
clientObligations: [
'Provide export shipment instructions and container release details for each movement.',
'Ensure containers are available at origin terminals per EDR operational windows.',
'Submit required export, customs, and operational documents at origin at least 24 hours before loading.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
'Provide safe transportation to SGTD and hand over for export processing when documents are complete.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildExportBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.',
scope: [
'Railway bulk export transport on the agreed corridor.',
'Loading coordination at origin terminals per EDR operational rules.',
],
},
clientObligations: [
'Provide accurate commodity description, weight, and export shipment instructions.',
'Ensure bulk cargo is prepared and available at origin per the agreed loading window.',
'Submit required export and customs documents at least 24 hours before loading where applicable.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildDomesticContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.',
scope: ['Domestic railway container transport between agreed origin and destination yards.'],
},
clientObligations: [
'Provide shipment instructions for each domestic container movement.',
'Ensure containers are available at origin per EDR operational rules.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.',
'Provide safe transportation and deliver within agreed timelines when instructions are complete.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildDomesticBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.',
scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'],
},
clientObligations: [
'Provide commodity description, weight, and shipment instructions for each movement.',
'Ensure cargo is prepared at origin per the agreed loading window.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation within agreed timelines.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
const BASE_PACKS: Record<ContractDirection, Record<ContractFreight, () => ContractClausePack>> = {
IMP: {
CON: buildImportContainerPack,
BULK: buildImportBulkPack,
},
EXP: {
CON: buildExportContainerPack,
BULK: buildExportBulkPack,
},
DOM: {
CON: buildDomesticContainerPack,
BULK: buildDomesticBulkPack,
},
};
export function buildClausePack(
direction: ContractDirection,
freight: ContractFreight,
service: ContractServiceScope,
): ContractClausePack {
const base = BASE_PACKS[direction][freight]();
return applyForwardingOverlay(base, service);
}
export function article1ObjectiveFromClause(article1: Article1Clause): string {
return article1.objective;
}

View File

@@ -0,0 +1,116 @@
import { existsSync } from 'fs';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="contract-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cover {
min-height: auto !important;
page-break-after: always;
}
.cover-title {
margin: 24mm 0 20mm !important;
}
}
</style>`;
@Injectable()
export class ContractPdfService {
private readonly logger = new Logger(ContractPdfService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, {
waitUntil: 'load',
timeout: 60_000,
});
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 400));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<span></span>',
footerTemplate:
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(
`Puppeteer produced invalid PDF (${buffer.length} bytes)`,
);
}
this.logger.log(
`Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (err) {
this.logger.error(
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
);
throw new InternalServerErrorException(
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('contract-pdf-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((p) => existsSync(p));
}
private isValidPdf(buffer: Buffer): boolean {
return (
buffer.length >= MIN_VALID_PDF_BYTES &&
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
);
}
}

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { BookingPricingService } from '../modules/bookings/booking-pricing.service';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto';
export interface PricingScheduleRow {
label: string;
description: string;
amount: number;
currency: string;
}
export interface PricingSchedule {
lineItems: PricingScheduleRow[];
surcharges: PricingScheduleRow[];
totalAmount: number;
currency: string;
equipmentReturn?: string;
originLabel: string;
destinationLabel: string;
containerLines: Array<{
label: string;
quantity: number;
vgmPerUnitTons: number;
}>;
}
@Injectable()
export class ContractPricingScheduleBuilder {
constructor(private readonly pricingService: BookingPricingService) {}
async build(booking: Booking): Promise<PricingSchedule> {
const { lineItems, totalAmount, currency } =
await this.pricingService.computeContractLineItems(booking);
const isSurcharge = (l: PriceLineItemDto) =>
l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge');
const baseLines = lineItems.filter((l) => !isSurcharge(l));
const surchargeLines = lineItems.filter(isSurcharge);
return {
lineItems: baseLines.map((l) => ({
label: l.code,
description: l.description,
amount: l.amount,
currency: l.currency,
})),
surcharges: surchargeLines.map((l) => ({
label: l.code,
description: l.description,
amount: l.amount,
currency: l.currency,
})),
totalAmount,
currency,
equipmentReturn: booking.equipmentReturn ?? '—',
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
destinationLabel:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
containerLines: (booking.bookingContainers ?? []).map((c) => ({
label:
c.containerType?.label ??
c.containerType?.code ??
c.containerTypeId ??
'—',
quantity: c.quantity,
vgmPerUnitTons: Number(c.vgmPerUnitTons),
})),
};
}
}

View File

@@ -0,0 +1,82 @@
import { ContractRendererService } from './contract-renderer.service';
import { getTemplateMeta } from './contract-template.registry';
import type { ContractViewModel } from './contract-view-model.builder';
describe('ContractRendererService', () => {
const renderer = new ContractRendererService();
renderer.onModuleInit();
function minimalView(templateKey: string): ContractViewModel {
const template = getTemplateMeta(templateKey);
return {
bookingId: 'test-id',
reference: 'BK-TEST-001',
status: 'CONTRACT_READY',
templateKey,
template,
contractDate: '1 January 2026',
contractYear: 2026,
client: {
companyName: 'Test Co',
companyAddress: 'Addis Ababa',
companyLocation: 'Ethiopia',
phone: '+251900000000',
email: 'test@example.com',
tinNumber: '1234567890',
vatNumber: 'VAT-001',
fanNumber: 'FAN-001',
businessLicense: 'BL-001',
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: {
originLabel: 'SGTD',
destinationLabel: 'Modjo',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: 'Rail transport',
scheduledDate: '1 January 2026',
contractType: 'NEW',
cargoDescription: 'Container cargo',
totalWeightVgm: '24 tons',
equipmentReturn: 'RETURN',
hazardousLabel: 'No',
firstMilePickupAddress: '—',
lastMileDeliveryAddress: '—',
},
pricing: {
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
surcharges: [],
totalAmount: 1000,
currency: 'ETB',
originLabel: 'SGTD',
destinationLabel: 'Modjo',
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
},
signatures: [],
canSignCustomer: true,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
};
}
it('renders import flagship with Nagad and Article 5', () => {
const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY'));
expect(html).toContain('Djibouti Nagad');
expect(html).toContain('Article 5: Contract Price');
expect(html).toContain('Article 2: Obligations of the Client');
});
it('renders export variant without import empty-return clause', () => {
const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY'));
expect(html).toContain('export corridors');
expect(html).not.toContain('Return empty containers from Dire Dawa');
});
});

View File

@@ -0,0 +1,51 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import Handlebars from 'handlebars';
import { ContractViewModel } from './contract-view-model.builder';
@Injectable()
export class ContractRendererService implements OnModuleInit {
private readonly templatesDir = path.join(__dirname, 'templates');
private readonly compiled = new Map<string, Handlebars.TemplateDelegate>();
onModuleInit(): void {
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
const partialsDir = path.join(this.templatesDir, '_partials');
if (fs.existsSync(partialsDir)) {
for (const file of fs.readdirSync(partialsDir)) {
if (!file.endsWith('.hbs')) continue;
const name = file.replace(/\.hbs$/, '');
const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
Handlebars.registerPartial(name, content);
}
}
}
render(view: ContractViewModel): string {
const fileName =
view.template.templateFile ?? 'generic.hbs';
const template = this.getCompiled(fileName);
return template({
...view,
paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
});
}
private getCompiled(fileName: string): Handlebars.TemplateDelegate {
const cached = this.compiled.get(fileName);
if (cached) return cached;
const filePath = path.join(this.templatesDir, fileName);
const fallbackPath = path.join(this.templatesDir, 'generic.hbs');
const source = fs.existsSync(filePath)
? fs.readFileSync(filePath, 'utf-8')
: fs.readFileSync(fallbackPath, 'utf-8');
const compiled = Handlebars.compile(source);
this.compiled.set(fileName, compiled);
return compiled;
}
}

View File

@@ -0,0 +1,68 @@
import {
CONTRACT_TEMPLATE_KEYS,
CONTRACT_TEMPLATE_REGISTRY,
getTemplateMeta,
isValidTemplateKey,
} from './contract-template.registry';
describe('ContractTemplateRegistry', () => {
it('defines exactly 24 template keys', () => {
expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24);
expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24);
});
it('keys match the direction_freight_currency_service pattern', () => {
for (const key of CONTRACT_TEMPLATE_KEYS) {
expect(isValidTemplateKey(key)).toBe(true);
}
});
it('each meta has non-empty obligations and article1 scope', () => {
for (const key of CONTRACT_TEMPLATE_KEYS) {
const meta = CONTRACT_TEMPLATE_REGISTRY[key]!;
expect(meta.clientObligations.length).toBeGreaterThan(0);
expect(meta.providerObligations.length).toBeGreaterThan(0);
expect(meta.article1.scope.length).toBeGreaterThan(0);
expect(meta.article1.objective.length).toBeGreaterThan(0);
expect(meta.contractDocuments.length).toBeGreaterThan(0);
}
});
it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => {
const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
expect(meta.direction).toBe('IMP');
expect(meta.freight).toBe('CON');
expect(meta.article1.objective).toContain('SGTD');
expect(meta.article1.objective).toContain('empty container return');
const clientText = meta.clientObligations.join(' ');
expect(clientText).toContain('Djibouti Nagad');
const providerText = meta.providerObligations.join(' ');
expect(providerText).toContain('seven (7) calendar days');
});
it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => {
const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY');
expect(meta.direction).toBe('EXP');
expect(meta.article1.objective).toContain('SGTD');
expect(meta.providerObligations.join(' ')).not.toContain(
'Return empty containers from Dire Dawa',
);
});
it('FORWARDING adds scope and provider obligations', () => {
const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING');
expect(forwarding.article1.scope.length).toBeGreaterThan(
transport.article1.scope.length,
);
expect(forwarding.providerObligations.length).toBeGreaterThan(
transport.providerObligations.length,
);
});
it('getTemplateMeta fallback includes clause arrays for unknown keys', () => {
const meta = getTemplateMeta('UNKNOWN_KEY');
expect(meta.clientObligations.length).toBeGreaterThan(0);
expect(meta.article1.scope.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,119 @@
import {
article1ObjectiveFromClause,
buildClausePack,
} from './contract-clause-packs';
import type {
ContractDirection,
ContractFreight,
ContractServiceScope,
ContractTemplateMeta,
} from './contract-template.types';
export type { ContractTemplateMeta } from './contract-template.types';
const DIRECTION_LABELS: Record<ContractDirection, string> = {
IMP: 'Import',
EXP: 'Export',
DOM: 'Domestic',
};
const FREIGHT_LABELS: Record<ContractFreight, string> = {
CON: 'Container',
BULK: 'Bulk',
};
const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM'];
const FREIGHTS: ContractFreight[] = ['CON', 'BULK'];
const CURRENCIES = ['ETB', 'USD'] as const;
const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING'];
const KEY_PATTERN =
/^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/;
function buildMeta(
dir: ContractDirection,
freight: ContractFreight,
currency: string,
service: ContractServiceScope,
): ContractTemplateMeta {
const key = `${dir}_${freight}_${currency}_${service}`;
const dirLabel = DIRECTION_LABELS[dir];
const freightLabel = FREIGHT_LABELS[freight];
const serviceLabel =
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
const corridor =
dir === 'IMP'
? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable'
: dir === 'EXP'
? 'from Ethiopian dry ports to SGTD and related export corridors'
: 'between designated Ethiopian rail terminals';
const clauses = buildClausePack(dir, freight, service);
return {
key,
direction: dir,
freight,
currency,
serviceScope: service,
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
directionLabel: dirLabel,
freightLabel,
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis AbabaDjibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
article1Objective: article1ObjectiveFromClause(clauses.article1),
article1: clauses.article1,
clientObligations: clauses.clientObligations,
providerObligations: clauses.providerObligations,
contractDocuments: clauses.contractDocuments,
};
}
/** Full template matrix (24 keys). */
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
{};
for (const dir of DIRECTIONS) {
for (const freight of FREIGHTS) {
for (const currency of CURRENCIES) {
for (const service of SERVICES) {
const meta = buildMeta(dir, freight, currency, service);
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
}
}
}
}
export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
export function listTemplateKeys(): string[] {
return CONTRACT_TEMPLATE_KEYS;
}
export function getTemplateMeta(key: string): ContractTemplateMeta {
const found = CONTRACT_TEMPLATE_REGISTRY[key];
if (found) return found;
const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY');
return {
key,
direction: 'IMP',
freight: 'CON',
currency: 'USD',
serviceScope: 'TRANSPORT_ONLY',
title: 'Freight Contract Agreement',
directionLabel: 'Freight',
freightLabel: 'Cargo',
whereas:
'The parties agree to railway freight services as described in the schedule below.',
article1Objective: article1ObjectiveFromClause(fallbackClauses.article1),
article1: fallbackClauses.article1,
clientObligations: fallbackClauses.clientObligations,
providerObligations: fallbackClauses.providerObligations,
contractDocuments: fallbackClauses.contractDocuments,
};
}
export function isValidTemplateKey(key: string): boolean {
return KEY_PATTERN.test(key);
}

View File

@@ -0,0 +1,65 @@
import { ContractTemplateResolver } from './contract-template.resolver';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
describe('ContractTemplateResolver', () => {
const resolver = new ContractTemplateResolver();
function booking(partial: Partial<Booking>): Booking {
return partial as Booking;
}
it('resolves import container ETB transport-only', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
paymentCurrency: 'ETB',
serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType,
}),
);
expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY');
});
it('resolves export bulk USD forwarding', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'EXPORT',
freightType: 'BULK',
paymentCurrency: 'USD',
serviceType: {
code: 'RAIL_FORWARDING',
includesFirstMile: true,
includesLastMile: false,
} as ServiceType,
}),
);
expect(key).toBe('EXP_BULK_USD_FORWARDING');
});
it('maps BREAK_BULK cargo to BULK freight', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
paymentCurrency: 'ETB',
cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType,
serviceType: undefined,
}),
);
expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY');
});
it('resolves domestic container', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
paymentCurrency: 'USD',
serviceType: undefined,
}),
);
expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY');
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
@Injectable()
export class ContractTemplateResolver {
resolve(booking: Booking): string {
const dir =
booking.tradeDirection === 'IMPORT'
? 'IMP'
: booking.tradeDirection === 'EXPORT'
? 'EXP'
: 'DOM';
let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON';
const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? '';
if (cargoCode.startsWith('BREAK_BULK')) {
freight = 'BULK';
}
const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
const service = this.resolveServiceScope(booking.serviceType);
return `${dir}_${freight}_${currency}_${service}`;
}
private resolveServiceScope(
serviceType?: ServiceType | null,
): 'TRANSPORT_ONLY' | 'FORWARDING' {
if (!serviceType) return 'TRANSPORT_ONLY';
const code = (serviceType.code ?? '').toUpperCase();
if (
serviceType.includesFirstMile ||
serviceType.includesLastMile ||
code.includes('FORWARD')
) {
return 'FORWARDING';
}
return 'TRANSPORT_ONLY';
}
}

View File

@@ -0,0 +1,35 @@
export type ContractDirection = 'IMP' | 'EXP' | 'DOM';
export type ContractFreight = 'CON' | 'BULK';
export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING';
export interface Article1Clause {
objective: string;
scope: string[];
}
export interface ContractClausePack {
article1: Article1Clause;
clientObligations: string[];
providerObligations: string[];
contractDocuments: string[];
}
export interface ContractTemplateMeta {
key: string;
direction: ContractDirection;
freight: ContractFreight;
currency: string;
serviceScope: ContractServiceScope;
title: string;
directionLabel: string;
freightLabel: string;
whereas: string;
/** Summary line for APIs; mirrors article1.objective */
article1Objective: string;
article1: Article1Clause;
clientObligations: string[];
providerObligations: string[];
contractDocuments: string[];
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
templateFile?: string;
}

View File

@@ -0,0 +1,209 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BookingsRepository } from '../modules/bookings/bookings.repository';
import { Booking } from '../modules/bookings/entities/booking.entity';
import {
BookingContractSignature,
ContractSignerRole,
} from '../modules/bookings/entities/booking-contract-signature.entity';
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
role: ContractSignerRole;
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
}
export interface ContractViewModel {
bookingId: string;
reference: string;
status: string;
templateKey: string;
template: ContractTemplateMeta;
contractDate: string;
contractYear: number;
client: {
companyName: string;
companyAddress: string;
companyLocation: string;
phone: string;
email: string;
tinNumber: string;
vatNumber: string;
fanNumber: string;
businessLicense: string;
};
provider: {
name: string;
address: string;
phone: string;
email: string;
tinNumber: string;
};
schedule: {
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
serviceType: string;
scheduledDate: string;
contractType: string;
cargoDescription: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
firstMilePickupAddress: string;
lastMileDeliveryAddress: string;
};
pricing: PricingSchedule;
signatures: ContractSignatureView[];
canSignCustomer: boolean;
canSignStaff: boolean;
hasContractDocument: boolean;
hasCustomerSignature: boolean;
hasStaffSignature: boolean;
}
@Injectable()
export class ContractViewModelBuilder {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const templateKey =
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const template = getTemplateMeta(templateKey);
const pricing = await this.pricingBuilder.build(booking);
const signatures = await this.loadSignatures(bookingId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
const hasContractFile = Boolean(
booking.files?.some((f) => f.code === 'contract'),
);
const view: ContractViewModel = {
bookingId: booking.id,
reference: booking.reference,
status: booking.status,
templateKey,
template,
contractDate: new Date().toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
client: {
companyName: booking.company?.name ?? 'Client',
companyAddress: this.valueOrDash(booking.company?.address),
companyLocation: this.valueOrDash(booking.company?.country),
phone: this.valueOrDash(booking.company?.phone),
email: this.valueOrDash(booking.company?.email),
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
businessLicense: this.valueOrDash(
booking.company?.companyProfiles?.[0]?.businessLicense,
),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: this.buildSchedule(booking),
pricing,
signatures,
canSignCustomer:
booking.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff:
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
};
return { booking, view };
}
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
return rows.map((s) => this.toSignatureView(s));
}
toSignatureView(row: BookingContractSignature): ContractSignatureView {
return {
role: row.signerRole,
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
};
}
private buildSchedule(booking: Booking): ContractViewModel['schedule'] {
const cargoName =
booking.freightType === 'BULK'
? booking.cargoFreeText ||
booking.cargoType?.cargoTypeName ||
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
return {
originLabel: this.yardLabel(booking.originYard),
destinationLabel: this.yardLabel(booking.destinationYard),
tradeDirection: this.valueOrDash(booking.tradeDirection),
freightType: this.valueOrDash(booking.freightType),
serviceType: this.valueOrDash(
booking.serviceType?.serviceName ?? booking.serviceType?.code,
),
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
hazardousLabel: booking.isHazardous ? 'Yes' : 'No',
firstMilePickupAddress: this.valueOrDash(
booking.firstMilePickupAddress,
),
lastMileDeliveryAddress: this.valueOrDash(
booking.lastMileDeliveryAddress,
),
};
}
private yardLabel(yard?: { label?: string; code?: string } | null): string {
return this.valueOrDash(yard?.label ?? yard?.code);
}
private formatDate(value?: Date | string | null): string {
if (!value) return '—';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
private valueOrDash(value?: string | number | null): string {
if (value === undefined || value === null || value === '') return '—';
return String(value);
}
}

View File

@@ -0,0 +1,15 @@
<div class="article">
<h2>Article 1: Objective and Scope of Services</h2>
<p>
<strong>1.1 Objective.</strong>
{{template.article1.objective}}
</p>
{{#if template.article1.scope.length}}
<p><strong>1.2 Scope of Services.</strong></p>
<ol>
{{#each template.article1.scope}}
<li>{{this}}</li>
{{/each}}
</ol>
{{/if}}
</div>

View File

@@ -0,0 +1,66 @@
<h2>Article 5: Contract Price and Terms of Payment</h2>
<div class="article">
<h3>Contract Price</h3>
<p>
The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate
schedule, and any approved operational surcharges.
</p>
<table class="details-table">
<tbody>
<tr>
<th>Corridor</th>
<td>{{pricing.originLabel}}{{pricing.destinationLabel}}</td>
<th>Currency</th>
<td>{{pricing.currency}}</td>
</tr>
<tr>
<th>Payment currency</th>
<td>{{paymentArticle}}</td>
<th>Equipment return</th>
<td>{{pricing.equipmentReturn}}</td>
</tr>
</tbody>
</table>
{{#if pricing.equipmentReturn}}
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
<h3>Charges</h3>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
{{#each pricing.lineItems}}
<tr>
<td>{{label}}</td>
<td>{{description}}</td>
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
{{#if pricing.surcharges.length}}
<tr>
<th colspan="3">Surcharges and Adjustments</th>
</tr>
{{#each pricing.surcharges}}
<tr>
<td>{{label}}</td>
<td>{{description}}</td>
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
{{/if}}
<tr class="total-row">
<td colspan="2"><strong>Total contract value</strong></td>
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
</tr>
</tbody>
</table>
<h3>Terms of payment</h3>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in
<strong>{{paymentArticle}}</strong> before the service is performed and in accordance with EDR payment
instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the
responsibility of the Client where applicable.
</p>
</div>

View File

@@ -0,0 +1,19 @@
<div class="article">
<h2>Article 2: Obligations of the Client</h2>
<p>The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:</p>
<ol>
{{#each template.clientObligations}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>
<div class="article">
<h2>Article 3: Obligations of the Service Provider</h2>
<p>EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:</p>
<ol>
{{#each template.providerObligations}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>

View File

@@ -0,0 +1,9 @@
<div class="article">
<h2>Article 6: Contract Documents</h2>
<p>The following documents form part of this Agreement and shall be read together with the signed contract:</p>
<ol>
{{#each template.contractDocuments}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>

View File

@@ -0,0 +1,65 @@
<section class="page-section">
<h2>Booking Schedule and Commercial Summary</h2>
<table class="details-table">
<tbody>
<tr>
<th>Route</th>
<td>{{schedule.originLabel}}{{schedule.destinationLabel}}</td>
<th>Trade direction</th>
<td>{{schedule.tradeDirection}}</td>
</tr>
<tr>
<th>Freight type</th>
<td>{{schedule.freightType}}</td>
<th>Service type</th>
<td>{{schedule.serviceType}}</td>
</tr>
<tr>
<th>Scheduled date</th>
<td>{{schedule.scheduledDate}}</td>
<th>Contract type</th>
<td>{{schedule.contractType}}</td>
</tr>
<tr>
<th>Cargo</th>
<td>{{schedule.cargoDescription}}</td>
<th>Total VGM</th>
<td>{{schedule.totalWeightVgm}}</td>
</tr>
<tr>
<th>Equipment return</th>
<td>{{schedule.equipmentReturn}}</td>
<th>Hazardous cargo</th>
<td>{{schedule.hazardousLabel}}</td>
</tr>
<tr>
<th>First mile</th>
<td>{{schedule.firstMilePickupAddress}}</td>
<th>Last mile</th>
<td>{{schedule.lastMileDeliveryAddress}}</td>
</tr>
</tbody>
</table>
{{#if pricing.containerLines.length}}
<h3>Container Details</h3>
<table class="schedule">
<thead>
<tr>
<th>Container type</th>
<th>Quantity</th>
<th>VGM / unit (tons)</th>
</tr>
</thead>
<tbody>
{{#each pricing.containerLines}}
<tr>
<td>{{label}}</td>
<td>{{quantity}}</td>
<td>{{vgmPerUnitTons}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</section>

View File

@@ -0,0 +1,12 @@
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>
Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control,
including natural disaster, war, civil unrest, government restriction, railway interruption, port closure,
or other force majeure events interpreted under the Ethiopian Civil Code.
</p>
<p>
The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the
effect of the force majeure event on the performance of this Agreement.
</p>
</div>

View File

@@ -0,0 +1,44 @@
<div class="signatures">
<div class="sig-block">
<p class="sig-title">For the Service Provider</p>
<p><strong>{{provider.name}}</strong></p>
{{#if hasStaffSignature}}
{{#each signatures}}
{{#if (eq role "STAFF")}}
<div class="sig-image-box">
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
</div>
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
<p class="sig-line"><strong>Name:</strong> Authorized representative</p>
<p class="sig-meta"><strong>Role:</strong> EDR representative</p>
<p class="sig-meta"><strong>Date:</strong></p>
{{/if}}
</div>
<div class="sig-block">
<p class="sig-title">For the Client</p>
<p><strong>{{client.companyName}}</strong></p>
{{#if hasCustomerSignature}}
{{#each signatures}}
{{#if (eq role "CUSTOMER")}}
<div class="sig-image-box">
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
</div>
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
<p class="sig-line"><strong>Name:</strong> Client representative</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong></p>
{{/if}}
</div>
</div>

View File

@@ -0,0 +1,258 @@
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 18mm 14mm; }
body {
margin: 0;
background: #f5f7fb;
color: #111827;
font-family: "Times New Roman", Times, serif;
font-size: 10.5pt;
line-height: 1.48;
}
.contract {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
background: #fff;
padding: 18mm 15mm;
}
h1, h2, h3, p { margin-top: 0; }
h1 {
color: #0f2742;
font-size: 18pt;
line-height: 1.25;
margin-bottom: 10px;
text-align: center;
text-transform: uppercase;
}
h2 {
border-bottom: 1.5px solid #1e3a5f;
color: #1e3a5f;
font-size: 12pt;
letter-spacing: 0.03em;
margin: 18px 0 10px;
padding-bottom: 5px;
text-transform: uppercase;
}
h3 {
color: #0f2742;
font-size: 10.8pt;
margin: 12px 0 6px;
}
p { margin-bottom: 8px; }
ol { margin: 6px 0 0; padding-left: 20px; }
li { margin-bottom: 5px; }
.page-section,
.article {
margin-bottom: 18px;
page-break-inside: avoid;
}
.brand-row {
align-items: center;
border-bottom: 3px solid #1e3a5f;
display: flex;
gap: 14px;
padding-bottom: 14px;
}
.logo-mark {
align-items: center;
background: #1e3a5f;
border-radius: 8px;
color: #fff;
display: flex;
font-family: Arial, sans-serif;
font-size: 16pt;
font-weight: 700;
height: 52px;
justify-content: center;
letter-spacing: 0.08em;
width: 72px;
}
.kicker {
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 10pt;
font-weight: 700;
letter-spacing: 0.04em;
margin-bottom: 2px;
text-transform: uppercase;
}
.muted {
color: #6b7280;
font-family: Arial, sans-serif;
font-size: 9pt;
margin: 0;
}
.cover {
min-height: 255mm;
position: relative;
}
.cover-title {
margin: 54mm 0 34mm;
text-align: center;
}
.document-label {
color: #6b7280;
font-family: Arial, sans-serif;
font-size: 10pt;
font-weight: 700;
letter-spacing: 0.12em;
margin-bottom: 10px;
text-transform: uppercase;
}
.summary-line {
color: #374151;
font-family: Arial, sans-serif;
font-size: 9.5pt;
margin-top: 12px;
}
table {
border-collapse: collapse;
width: 100%;
}
.meta-grid,
.details-table,
.schedule {
font-size: 9.5pt;
margin: 10px 0 16px;
}
.meta-grid th,
.meta-grid td,
.details-table th,
.details-table td,
.schedule th,
.schedule td {
border: 1px solid #cbd5e1;
padding: 7px 8px;
text-align: left;
vertical-align: top;
}
.meta-grid th,
.details-table th,
.schedule th {
background: #eef4fb;
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 8.5pt;
text-transform: uppercase;
}
.schedule tbody tr:nth-child(even) td { background: #f8fafc; }
.total-row td {
background: #e8f0f8 !important;
color: #0f2742;
font-weight: 700;
}
.lead {
color: #374151;
font-size: 10.5pt;
}
.party-grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
}
.party-card {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 12px;
}
.party-card h3 {
background: #1e3a5f;
border-radius: 5px;
color: #fff;
font-family: Arial, sans-serif;
font-size: 9pt;
margin: 0 0 10px;
padding: 7px 9px;
text-transform: uppercase;
}
.party-name {
color: #0f2742;
font-weight: 700;
margin-bottom: 8px;
}
dl {
display: grid;
grid-template-columns: 32% 68%;
margin: 0;
}
dt {
color: #475569;
font-family: Arial, sans-serif;
font-size: 8.5pt;
font-weight: 700;
padding: 2px 6px 2px 0;
}
dd {
margin: 0;
padding: 2px 0;
}
.signatures {
display: grid;
gap: 18px;
grid-template-columns: 1fr 1fr;
margin-top: 24px;
page-break-inside: avoid;
}
.sig-block {
border: 1.5px solid #1e3a5f;
border-radius: 8px;
min-height: 96mm;
padding: 12px;
}
.sig-title {
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 9pt;
font-weight: 700;
margin-bottom: 10px;
text-transform: uppercase;
}
.sig-image-box {
align-items: center;
border: 1px dashed #94a3b8;
display: flex;
height: 28mm;
justify-content: center;
margin: 14px 0;
}
.sig-image-box img {
display: block;
max-height: 24mm;
max-width: 70mm;
}
.sig-placeholder {
color: #94a3b8;
font-family: Arial, sans-serif;
font-size: 8.5pt;
}
.sig-line {
border-top: 1px solid #111827;
margin-top: 16px;
padding-top: 5px;
}
.sig-meta {
color: #475569;
font-size: 9pt;
margin: 4px 0;
}
@media print {
body { background: #fff; }
.contract {
margin: 0;
padding: 0;
width: auto;
}
.cover { page-break-after: always; }
}
</style>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{template.title}}{{reference}}</title>
{{> styles}}
</head>
<body>
<main class="contract">
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Contract</p>
</div>
</div>
<div class="cover-title">
<p class="document-label">Contract Agreement</p>
<h1>{{template.title}}</h1>
<p class="summary-line">{{template.directionLabel}}{{template.freightLabel}}{{template.currency}}{{template.serviceScope}}</p>
</div>
<table class="meta-grid">
<tr>
<th>Contract Ref No.</th>
<td>{{reference}}</td>
<th>Contract Year</th>
<td>{{contractYear}}</td>
</tr>
<tr>
<th>Contract Date</th>
<td>{{contractDate}}</td>
<th>Status</th>
<td>{{status}}</td>
</tr>
</table>
</section>
<section class="page-section">
<h2>Parties to the Agreement</h2>
<p class="lead">
This Contract Agreement is made on <strong>{{contractDate}}</strong> between the Service Provider and the Client named below.
</p>
<div class="party-grid">
<div class="party-card">
<h3>Service Provider</h3>
<p class="party-name">{{provider.name}}</p>
<dl>
<dt>Address</dt><dd>{{provider.address}}</dd>
<dt>Phone</dt><dd>{{provider.phone}}</dd>
<dt>Email</dt><dd>{{provider.email}}</dd>
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
</dl>
</div>
<div class="party-card">
<h3>Client</h3>
<p class="party-name">{{client.companyName}}</p>
<dl>
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
<dt>Phone</dt><dd>{{client.phone}}</dd>
<dt>Email</dt><dd>{{client.email}}</dd>
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
<dt>FAN</dt><dd>{{client.fanNumber}}</dd>
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
</dl>
</div>
</div>
</section>
{{> contract_schedule}}
<section class="page-section">
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
</section>
{{> article1}}
{{> articles_obligations}}
{{> force_majeure}}
{{> article5_pricing}}
{{> contract_documents}}
{{> signatures_block}}
</main>
</body>
</html>

View File

@@ -0,0 +1,21 @@
// apps/edr-freight-api/src/data-source.ts
import 'dotenv/config';
import { DataSource } from 'typeorm';
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5433),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_freight',
schema: 'freight', // default schema for entities without an explicit schema
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,
logging: process.env.TYPEORM_LOGGING === 'true',
});
// Optional: call ensurePostgresSchemas before initializing
// But you can also run it separately.

View File

@@ -1,4 +1,6 @@
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import {
@@ -10,7 +12,31 @@ import {
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule);
// Dev CORS: reflect any localhost origin and allow credentials so the
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
// and any other dev port can call the API with cookies + Authorization.
// For production, restrict `origin` to known FQDNs.
app.enableCors({
origin: true, // reflect request origin
credentials: true,
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Accept",
"Authorization",
"X-Requested-With",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",
"current-project-id",
"current-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());
@@ -27,9 +53,12 @@ async function bootstrap() {
SwaggerModule.setup("api/docs", app, document);
const port = parseInt(process.env.PORT ?? "3001", 10);
await app.listen(port);
// await app.listen(port, "0.0.0.0");
await app.listen(
port)
// eslint-disable-next-line no-console
console.log(`[freight-api] listening on http://localhost:${port}`);
console.log(`[freight-api] listening on port ${port}`);
}
bootstrap();

View File

@@ -0,0 +1,228 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from "typeorm";
export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInterface {
name = "AddServiceTypesAndCargoTypes1748427600000";
public async up(queryRunner: QueryRunner): Promise<void> {
// Create service_types table
if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable(
new Table({
name: "service_types",
schema: "freight",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "uuid_generate_v4()",
},
{
name: "service_name",
type: "varchar",
length: "255",
isNullable: false,
},
{
name: "description",
type: "text",
isNullable: true,
},
{
name: "can_be_booked_alone",
type: "boolean",
default: true,
isNullable: false,
},
{
name: "includes_first_mile",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "includes_last_mile",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "includes_customs",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "priority_bonus_points",
type: "int",
default: 0,
isNullable: false,
},
{
name: "is_active",
type: "boolean",
default: true,
isNullable: false,
},
{
name: "display_order",
type: "int",
default: 1,
isNullable: false,
},
{
name: "created_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "updated_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "deleted_at",
type: "timestamptz",
isNullable: true,
},
],
}),
true,
);
// Create indexes for service_types
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
columnNames: ["is_active"],
}),
);
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
columnNames: ["display_order"],
}),
);
// Create cargo_types table
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
new Table({
name: "cargo_types",
schema: "freight",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "uuid_generate_v4()",
},
{
name: "cargo_type_name",
type: "varchar",
length: "255",
isNullable: false,
},
{
name: "parent_group_id",
type: "uuid",
isNullable: true,
},
{
name: "show_free_text_box",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "requires_director_approval",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "is_active",
type: "boolean",
default: true,
isNullable: false,
},
{
name: "display_order",
type: "int",
default: 1,
isNullable: false,
},
{
name: "created_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "updated_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "deleted_at",
type: "timestamptz",
isNullable: true,
},
],
}),
true,
);
// Create indexes for cargo_types
await queryRunner.createIndex(
"freight.cargo_types",
new TableIndex({
name: "IDX_CARGO_TYPES_IS_ACTIVE",
columnNames: ["is_active"],
}),
);
await queryRunner.createIndex(
"freight.cargo_types",
new TableIndex({
name: "IDX_CARGO_TYPES_DISPLAY_ORDER",
columnNames: ["display_order"],
}),
);
await queryRunner.createIndex(
"freight.cargo_types",
new TableIndex({
name: "IDX_CARGO_TYPES_PARENT_GROUP_ID",
columnNames: ["parent_group_id"],
}),
);
// Create self-referencing foreign key for cargo_types
await queryRunner.createForeignKey(
"freight.cargo_types",
new TableForeignKey({
name: "FK_CARGO_TYPES_PARENT_GROUP",
columnNames: ["parent_group_id"],
referencedSchema: "freight",
referencedTableName: "cargo_types",
referencedColumnNames: ["id"],
onDelete: "SET NULL",
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop foreign key first
await queryRunner.dropForeignKey("freight.cargo_types", "FK_CARGO_TYPES_PARENT_GROUP");
// Drop cargo_types table
await queryRunner.dropTable("freight.cargo_types", true);
// Drop service_types table
await queryRunner.dropTable("freight.service_types", true);
}
}

View File

@@ -0,0 +1,293 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
TableColumn,
} from 'typeorm';
export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface {
name = 'AddRuleEngineTablesAndCodes1748514000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Add `code` column to existing tables ───────────────────────────
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
);
const serviceTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`,
);
if (serviceTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.service_types',
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
);
}
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
);
const cargoTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`,
);
if (cargoTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.cargo_types',
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
);
}
// ── 2. surcharge_types ────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable(
new Table({
name: 'surcharge_types',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '50', isNullable: false },
{ name: 'name', type: 'varchar', length: '100', isNullable: false },
{ name: 'description', type: 'text', isNullable: true },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.surcharge_types',
new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.surcharge_types',
new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }),
);
// ── 3. surcharges ─────────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable(
new Table({
name: 'surcharges',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'surcharge_type_id', type: 'uuid', isNullable: false },
{ name: 'fee_name', type: 'varchar', length: '255', isNullable: false },
{ name: 'trigger_description', type: 'text', isNullable: true },
{
name: 'calculation_method',
type: 'enum',
enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'],
default: `'PER_TON'`,
},
{ name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{ name: 'currency', type: 'char', length: '3', default: `'USD'` },
{ name: 'apply_to_rail', type: 'boolean', default: false },
{ name: 'apply_to_first_mile', type: 'boolean', default: false },
{ name: 'apply_to_last_mile', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.surcharges',
new TableForeignKey({
name: 'FK_surcharges_surcharge_type',
columnNames: ['surcharge_type_id'],
referencedTableName: 'freight.surcharge_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);
await queryRunner.createIndex(
'freight.surcharges',
new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }),
);
await queryRunner.createIndex(
'freight.surcharges',
new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }),
);
// ── 4. container_types ────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable(
new Table({
name: 'container_types',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'size_code', type: 'varchar', length: '20', isNullable: false },
{ name: 'description', type: 'varchar', length: '100', isNullable: true },
{ name: 'containers_per_wagon', type: 'int', isNullable: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.container_types',
new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.container_types',
new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }),
);
// ── 5. weight_limit_rules ─────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable(
new Table({
name: 'weight_limit_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'container_type_id', type: 'uuid', isNullable: false },
{
name: 'trade_direction',
type: 'enum',
enum: ['IMPORT', 'EXPORT', 'BOTH'],
isNullable: false,
},
{ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{
name: 'exceeded_action',
type: 'enum',
enum: ['WARNING_ONLY', 'HARD_BLOCK'],
default: `'WARNING_ONLY'`,
},
{ name: 'surcharge_id', type: 'uuid', isNullable: true },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.weight_limit_rules',
new TableForeignKey({
name: 'FK_weight_limit_rules_container_type',
columnNames: ['container_type_id'],
referencedTableName: 'freight.container_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);
await queryRunner.createForeignKey(
'freight.weight_limit_rules',
new TableForeignKey({
name: 'FK_weight_limit_rules_surcharge',
columnNames: ['surcharge_id'],
referencedTableName: 'freight.surcharges',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }),
);
// ── 6. priority_rules ─────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable(
new Table({
name: 'priority_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{
name: 'priority_type',
type: 'enum',
enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'],
isNullable: false,
},
{ name: 'rule_name', type: 'varchar', length: '255', isNullable: false },
{ name: 'description', type: 'text', isNullable: true },
{ name: 'activation_condition', type: 'text', isNullable: true },
{ name: 'bonus_points', type: 'int', default: 0 },
{ name: 'is_active', type: 'boolean', default: false },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.priority_rules', true);
await queryRunner.dropTable('freight.weight_limit_rules', true);
await queryRunner.dropTable('freight.container_types', true);
await queryRunner.dropTable('freight.surcharges', true);
await queryRunner.dropTable('freight.surcharge_types', true);
await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code');
await queryRunner.dropColumn('freight.cargo_types', 'code');
await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code');
await queryRunner.dropColumn('freight.service_types', 'code');
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings`
* via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table.
*/
export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface {
name = 'CreateFreightLegacyBaseline1748550000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.train_status AS ENUM (
'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.trains (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(32) NOT NULL UNIQUE,
capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0,
status freight.train_status NOT NULL DEFAULT 'AVAILABLE',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.bookings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reference VARCHAR(64) NOT NULL UNIQUE,
customer_id UUID NOT NULL,
train_id UUID,
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(),
total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT',
previous_contract_id UUID,
trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT',
equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN',
first_mile_pickup_address TEXT,
last_mile_delivery_address TEXT,
cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0,
is_hazardous BOOLEAN NOT NULL DEFAULT false,
payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD',
start_date DATE,
end_date DATE,
financial_terms TEXT,
version_number INT NOT NULL DEFAULT 1,
approved_by_staff_id UUID,
approved_by_staff_at TIMESTAMPTZ,
signed_by_director_id UUID,
signed_by_director_at TIMESTAMPTZ,
signed_by_ceo_id UUID,
signed_by_ceo_at TIMESTAMPTZ,
priority_score INT NOT NULL DEFAULT 0,
allow_consolidation BOOLEAN NOT NULL DEFAULT false,
consolidation_partner_id UUID,
origin_station VARCHAR(255),
destination_station VARCHAR(255),
service_type VARCHAR(100),
freight_type VARCHAR(100),
freight_subtype VARCHAR(255),
containers JSONB,
first_mile_enabled BOOLEAN DEFAULT false,
last_mile_enabled BOOLEAN DEFAULT false,
is_refrigerated BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`);
}
}

View File

@@ -0,0 +1,544 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
TableUnique,
} from 'typeorm';
export class ItmlsFullSchemaRewrite1748600000000 implements MigrationInterface {
name = 'ItmlsFullSchemaRewrite1748600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── container_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN size_code TO code;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN description TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS size_ft SMALLINT,
ADD COLUMN IF NOT EXISTS is_reefer BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_open_top BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS display_order INT NOT NULL DEFAULT 1;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagons_per_unit NUMERIC(4,2);
`);
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = CASE
WHEN containers_per_wagon > 0 THEN ROUND(1.0 / containers_per_wagon, 2)
ELSE 1.00
END
WHERE wagons_per_unit IS NULL;
`);
await queryRunner.query(`
UPDATE freight.container_types
SET size_ft = CASE WHEN code LIKE '40%' OR code LIKE '%40%' THEN 40 ELSE 20 END
WHERE size_ft IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ALTER COLUMN wagons_per_unit SET NOT NULL,
DROP COLUMN IF EXISTS containers_per_wagon;
`);
// ── weight_limit_rules ──────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.weight_limit_rules RENAME COLUMN max_weight_tons TO max_vgm_tons;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ALTER COLUMN max_vgm_tons TYPE NUMERIC(8,3);
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
ADD COLUMN IF NOT EXISTS effective_to DATE;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS warning_threshold_tons,
DROP COLUMN IF EXISTS exceeded_action,
DROP COLUMN IF EXISTS surcharge_id,
DROP COLUMN IF EXISTS is_active;
`);
// ── priority_rules ────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ADD COLUMN IF NOT EXISTS code VARCHAR(40),
ADD COLUMN IF NOT EXISTS label VARCHAR(100),
ADD COLUMN IF NOT EXISTS score INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS condition_currency VARCHAR(5);
`);
await queryRunner.query(`
UPDATE freight.priority_rules
SET code = COALESCE(code, upper(priority_type::text)),
label = COALESCE(label, rule_name),
score = COALESCE(score, bonus_points)
WHERE code IS NULL OR label IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
DROP COLUMN IF EXISTS priority_type,
DROP COLUMN IF EXISTS rule_name,
DROP COLUMN IF EXISTS bonus_points,
DROP COLUMN IF EXISTS activation_condition,
DROP COLUMN IF EXISTS description;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ALTER COLUMN code SET NOT NULL,
ALTER COLUMN label SET NOT NULL;
`);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'UQ_priority_rules_code', columnNames: ['code'], isUnique: true }),
);
// ── rates (before surcharge_types.rate_id) ────────────────────────────
await queryRunner.createTable(
new Table({
name: 'rates',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'container_type_id', type: 'uuid', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '10', isNullable: true },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'proposed_by_staff_id', type: 'uuid' },
{ name: 'approved_by_ceo_id', type: 'uuid', isNullable: true },
{ name: 'approved_at', type: 'timestamptz', isNullable: true },
{ name: 'effective_from', type: 'date' },
{ name: 'effective_to', type: 'date', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── surcharge_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.surcharge_types RENAME COLUMN name TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.surcharge_types
ADD COLUMN IF NOT EXISTS trigger_condition VARCHAR(50),
ADD COLUMN IF NOT EXISTS rate_id UUID;
`);
await queryRunner.query(`ALTER TABLE freight.surcharge_types DROP COLUMN IF EXISTS description`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharges CASCADE`);
// ── yards ─────────────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'yards',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'country', type: 'varchar', length: '50' },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'display_order', type: 'int', default: 1 },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.yards',
new TableIndex({ name: 'UQ_yards_code', columnNames: ['code'], isUnique: true }),
);
// ── shipping_lines ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'shipping_lines',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'mapped_to_code', type: 'varchar', length: '20', isNullable: true },
{ name: 'show_extra_fee_notice', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── approval_rules ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'approval_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'requires_director_approval', type: 'boolean' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'action_label', type: 'varchar', length: '50' },
{ name: 'blocks_role', type: 'varchar', length: '30', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createUniqueConstraint(
'freight.approval_rules',
new TableUnique({
name: 'UQ_approval_rules_chain_step',
columnNames: ['requires_director_approval', 'step_order'],
}),
);
// ── bookings ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_yard_id UUID,
ADD COLUMN IF NOT EXISTS destination_yard_id UUID,
ADD COLUMN IF NOT EXISTS service_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_free_text VARCHAR(200),
ADD COLUMN IF NOT EXISTS shipping_line_id UUID,
ADD COLUMN IF NOT EXISTS pnr_code VARCHAR(50),
ADD COLUMN IF NOT EXISTS customer_signed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fully_executed_at TIMESTAMPTZ;
`);
await queryRunner.query(`
INSERT INTO freight.yards (id, code, label, country, is_active, display_order, created_at, updated_at)
VALUES
(uuid_generate_v4(), 'KALITY', 'Kality Rail Terminal', 'Ethiopia', true, 1, now(), now()),
(uuid_generate_v4(), 'MOJO', 'Mojo Dry Port', 'Ethiopia', true, 2, now(), now()),
(uuid_generate_v4(), 'DIRE_DAWA', 'Dire Dawa Yard', 'Ethiopia', true, 3, now(), now()),
(uuid_generate_v4(), 'DJIB_PORT', 'Djibouti Port Terminal', 'Djibouti', true, 4, now(), now()),
(uuid_generate_v4(), 'NAGAD', 'Nagad Terminal, Djibouti', 'Djibouti', true, 5, now(), now()),
(uuid_generate_v4(), 'LEGACY_ORIGIN', 'Legacy Origin', 'Ethiopia', true, 99, now(), now()),
(uuid_generate_v4(), 'LEGACY_DEST', 'Legacy Destination', 'Ethiopia', true, 100, now(), now())
ON CONFLICT (code) DO NOTHING;
`);
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
const hasServiceTypeCol = await queryRunner.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'bookings' AND column_name = 'service_type'
LIMIT 1;
`);
if (hasServiceTypeCol.length > 0) {
await queryRunner.query(`
UPDATE freight.bookings b
SET service_type_id = st.id
FROM freight.service_types st
WHERE b.service_type_id IS NULL
AND (
st.code = b.service_type
OR upper(replace(st.service_name, ' ', '_')) = upper(b.service_type)
OR st.code = upper(replace(b.service_type, ' ', '_'))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_type_id = ct.id
FROM freight.cargo_types ct
WHERE b.cargo_type_id IS NULL
AND (
ct.code = upper(b.freight_type)
OR ct.code = upper(concat(b.freight_type, '_', coalesce(b.freight_subtype, '')))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_free_text = b.freight_subtype
WHERE b.cargo_free_text IS NULL AND b.freight_subtype IS NOT NULL;
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET origin_yard_id = y.id
FROM freight.yards y
WHERE b.origin_yard_id IS NULL
AND (y.label ILIKE b.origin_station OR y.code = upper(replace(b.origin_station, ' ', '_')));
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET destination_yard_id = y.id
FROM freight.yards y
WHERE b.destination_yard_id IS NULL
AND (y.label ILIKE b.destination_station OR y.code = upper(replace(b.destination_station, ' ', '_')));
`);
}
const defaultServiceTypeId = await queryRunner.query(
`SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1`,
);
const defaultCargoTypeId = await queryRunner.query(
`SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1`,
);
const legacyOriginId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_ORIGIN' LIMIT 1`,
);
const legacyDestId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_DEST' LIMIT 1`,
);
if (defaultServiceTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET service_type_id = $1 WHERE service_type_id IS NULL`,
[defaultServiceTypeId[0].id],
);
}
if (defaultCargoTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET cargo_type_id = $1 WHERE cargo_type_id IS NULL`,
[defaultCargoTypeId[0].id],
);
}
if (legacyOriginId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET origin_yard_id = $1 WHERE origin_yard_id IS NULL`,
[legacyOriginId[0].id],
);
}
if (legacyDestId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET destination_yard_id = $1 WHERE destination_yard_id IS NULL`,
[legacyDestId[0].id],
);
}
const nullBookings = await queryRunner.query(
`SELECT COUNT(*)::int AS cnt FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`,
);
if (nullBookings[0]?.cnt > 0) {
await queryRunner.query(`DELETE FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`);
}
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN service_type_id SET NOT NULL,
ALTER COLUMN cargo_type_id SET NOT NULL,
ALTER COLUMN origin_yard_id SET NOT NULL,
ALTER COLUMN destination_yard_id SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_station,
DROP COLUMN IF EXISTS destination_station,
DROP COLUMN IF EXISTS service_type,
DROP COLUMN IF EXISTS freight_type,
DROP COLUMN IF EXISTS freight_subtype,
DROP COLUMN IF EXISTS containers,
DROP COLUMN IF EXISTS first_mile_enabled,
DROP COLUMN IF EXISTS last_mile_enabled,
DROP COLUMN IF EXISTS is_refrigerated;
`);
// ── booking_container ─────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'booking_container',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'container_type_id', type: 'uuid' },
{ name: 'quantity', type: 'smallint' },
{ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 },
{ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 },
{ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 },
{ name: 'weight_limit_rule_id', type: 'uuid', isNullable: true },
{ name: 'is_overweight', type: 'boolean', default: false },
{ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_rate_snapshot',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'rate_id', type: 'uuid' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'snapshotted_at', type: 'timestamptz' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_cargo_modifier',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'surcharge_type_id', type: 'uuid' },
{ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, isNullable: true },
{ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 },
{ name: 'rate_snapshot_id', type: 'uuid' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_approval_step',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'approval_rule_id', type: 'uuid' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
{ name: 'actioned_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'actioned_at', type: 'timestamptz', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// Foreign keys
await queryRunner.createForeignKey(
'freight.surcharge_types',
new TableForeignKey({
name: 'FK_surcharge_types_rate_id',
columnNames: ['rate_id'],
referencedTableName: 'rates',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.booking_container',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'bookings',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['origin_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['destination_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_approval_step', true);
await queryRunner.dropTable('freight.booking_cargo_modifier', true);
await queryRunner.dropTable('freight.booking_rate_snapshot', true);
await queryRunner.dropTable('freight.booking_container', true);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS destination_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS service_type VARCHAR(30),
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20),
ADD COLUMN IF NOT EXISTS freight_subtype VARCHAR(100),
ADD COLUMN IF NOT EXISTS containers JSONB,
ADD COLUMN IF NOT EXISTS first_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS last_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_refrigerated BOOLEAN DEFAULT false;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_yard_id,
DROP COLUMN IF EXISTS destination_yard_id,
DROP COLUMN IF EXISTS service_type_id,
DROP COLUMN IF EXISTS cargo_type_id,
DROP COLUMN IF EXISTS cargo_free_text,
DROP COLUMN IF EXISTS shipping_line_id,
DROP COLUMN IF EXISTS pnr_code,
DROP COLUMN IF EXISTS customer_signed_at,
DROP COLUMN IF EXISTS fully_executed_at;
`);
await queryRunner.dropTable('freight.approval_rules', true);
await queryRunner.dropTable('freight.shipping_lines', true);
await queryRunner.dropTable('freight.yards', true);
await queryRunner.dropTable('freight.rates', true);
}
}

View File

@@ -0,0 +1,94 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface {
name = 'AddBookingsConfigForeignKeys1748700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Ensure parent config rows exist for backfill
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
// Clear orphan shipping_line references (nullable FK)
await queryRunner.query(`
UPDATE freight.bookings b
SET shipping_line_id = NULL
WHERE b.shipping_line_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.shipping_lines sl WHERE sl.id = b.shipping_line_id
);
`);
// Backfill required FK columns
await queryRunner.query(`
UPDATE freight.bookings
SET service_type_id = (SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1)
WHERE service_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.service_types st WHERE st.id = service_type_id);
`);
await queryRunner.query(`
UPDATE freight.bookings
SET cargo_type_id = (SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1)
WHERE cargo_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.cargo_types ct WHERE ct.id = cargo_type_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_service_type_id"
FOREIGN KEY (service_type_id)
REFERENCES freight.service_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_cargo_type_id"
FOREIGN KEY (cargo_type_id)
REFERENCES freight.cargo_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_shipping_line_id"
FOREIGN KEY (shipping_line_id)
REFERENCES freight.shipping_lines(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_shipping_line_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_cargo_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_service_type_id";
`);
}
}

View File

@@ -0,0 +1,331 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationInterface {
name = 'AddBookingsRemainingForeignKeys1748800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const publicCustomersExists = await queryRunner.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'customers'
) AS exists
`);
const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists);
// ── freight.bookings: nullable FK cleanup ─────────────────────────────
await queryRunner.query(`
UPDATE freight.bookings b
SET train_id = NULL
WHERE b.train_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.id = b.train_id);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET previous_contract_id = NULL
WHERE b.previous_contract_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.previous_contract_id);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET consolidation_partner_id = NULL
WHERE b.consolidation_partner_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id);
`);
// Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890).
if (hasPublicCustomers) {
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
// ── freight.bookings FKs ────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_train_id"
FOREIGN KEY (train_id)
REFERENCES freight.trains(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_previous_contract_id"
FOREIGN KEY (previous_contract_id)
REFERENCES freight.bookings(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_consolidation_partner_id"
FOREIGN KEY (consolidation_partner_id)
REFERENCES freight.bookings(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_container ─────────────────────────────────────────
await queryRunner.query(`
UPDATE freight.booking_container bc
SET weight_limit_rule_id = NULL
WHERE bc.weight_limit_rule_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.weight_limit_rules wlr WHERE wlr.id = bc.weight_limit_rule_id
);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types ct WHERE ct.id = bc.container_type_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_container
ADD CONSTRAINT "FK_booking_container_container_type_id"
FOREIGN KEY (container_type_id)
REFERENCES freight.container_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_container
ADD CONSTRAINT "FK_booking_container_weight_limit_rule_id"
FOREIGN KEY (weight_limit_rule_id)
REFERENCES freight.weight_limit_rules(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_rate_snapshot ─────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.booking_rate_snapshot brs
WHERE bcm.rate_snapshot_id = brs.id
AND (
NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id)
);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_rate_snapshot
ADD CONSTRAINT "FK_booking_rate_snapshot_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_rate_snapshot
ADD CONSTRAINT "FK_booking_rate_snapshot_rate_id"
FOREIGN KEY (rate_id)
REFERENCES freight.rates(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_approval_step ─────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bas.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.approval_rules ar WHERE ar.id = bas.approval_rule_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_approval_step
ADD CONSTRAINT "FK_booking_approval_step_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_approval_step
ADD CONSTRAINT "FK_booking_approval_step_approval_rule_id"
FOREIGN KEY (approval_rule_id)
REFERENCES freight.approval_rules(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_cargo_modifier ────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bcm.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.surcharge_types st WHERE st.id = bcm.surcharge_type_id)
OR NOT EXISTS (
SELECT 1 FROM freight.booking_rate_snapshot brs WHERE brs.id = bcm.rate_snapshot_id
);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_surcharge_type_id"
FOREIGN KEY (surcharge_type_id)
REFERENCES freight.surcharge_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_snapshot_id"
FOREIGN KEY (rate_snapshot_id)
REFERENCES freight.booking_rate_snapshot(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_snapshot_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_approval_rule_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_rate_snapshot
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_rate_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_rate_snapshot
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP CONSTRAINT IF EXISTS "FK_booking_container_weight_limit_rule_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP CONSTRAINT IF EXISTS "FK_booking_container_container_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_consolidation_partner_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_previous_contract_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_train_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
}
}

View File

@@ -0,0 +1,185 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveCustomersToFreightSchema1748900000000 implements MigrationInterface {
name = 'MoveCustomersToFreightSchema1748900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
phone VARCHAR(20) NOT NULL,
company_name VARCHAR(200) NOT NULL,
company_email VARCHAR(150) NOT NULL,
company_phone VARCHAR(20) NOT NULL,
company_location VARCHAR(100) NOT NULL,
company_address TEXT NOT NULL,
customer_type VARCHAR(32),
status VARCHAR(32),
contact_person_name VARCHAR(100) NOT NULL,
contact_person_phone VARCHAR(20) NOT NULL,
tin_number VARCHAR(10) NOT NULL UNIQUE,
vat_number VARCHAR(50),
fan_number VARCHAR(16) NOT NULL UNIQUE,
general_manager_name VARCHAR(100) NOT NULL,
general_manager_email VARCHAR(150) NOT NULL,
general_manager_phone VARCHAR(20) NOT NULL,
poa_name VARCHAR(100),
poa_phone VARCHAR(20),
poa_address TEXT,
poa_email VARCHAR(150),
poa_location VARCHAR(100),
notes 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_freight_customers_email"
ON freight.customers (email);
`);
// await queryRunner.query(`
// CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
// ON freight.customers (user_id);
//`);
// Copy rows from public.customers when that legacy table exists
await queryRunner.query(`
DO $$
DECLARE
has_public boolean;
has_user_id boolean;
has_userid boolean;
BEGIN
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'customers'
) INTO has_public;
IF NOT has_public THEN
RETURN;
END IF;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'user_id'
) INTO has_user_id;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'userid'
) INTO has_userid;
IF has_user_id THEN
INSERT INTO freight.customers (
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
created_at, updated_at
)
SELECT
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
COALESCE(created_at, now()), COALESCE(updated_at, now())
FROM public.customers
ON CONFLICT (id) DO NOTHING;
ELSIF has_userid THEN
INSERT INTO freight.customers (
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
created_at, updated_at
)
SELECT
id, userid, firstname, lastname, email, phone,
companyname, companyemail, companyphone, companylocation, companyaddress,
contactpersonname, contactpersonphone, tinnumber, vatnumber, fannumber,
generalmanagername, generalmanageremail, generalmanagerphone,
poaname, poaphone, poaaddress, poaemail, poalocation, notes,
COALESCE("createdAt", now()), COALESCE("updatedAt", now())
FROM public.customers
ON CONFLICT (id) DO NOTHING;
END IF;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES freight.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.customers CASCADE;`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY).
*/
export class NormalizeWeightLimitTradeDirectionBoth1749000000000
implements MigrationInterface
{
name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
UPDATE freight.weight_limit_rules
SET trade_direction = 'BOTH'
WHERE trade_direction::text = 'ANY';
UPDATE freight.weight_limit_rules
SET trade_direction = 'IMPORT'
WHERE trade_direction IS NULL;
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
END $$;
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No-op: ANY is not a valid enum value in PostgreSQL.
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateFreightFilesTable1749100000000 implements MigrationInterface {
name = 'CreateFreightFilesTable1749100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource_id UUID NOT NULL,
resource VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL,
name VARCHAR(500) NOT NULL,
url TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type VARCHAR(255) NOT NULL,
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_freight_files_resource"
ON freight.files (resource_id, resource);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code"
ON freight.files (resource_id, resource, code);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`);
}
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BookingFlowRefactor1749200000000 implements MigrationInterface {
name = 'BookingFlowRefactor1749200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_review_note (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
author_id UUID,
note TEXT NOT NULL,
type VARCHAR(30) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
ON freight.booking_review_note(booking_id);
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS contract_summary TEXT,
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
`);
await queryRunner.query(`
UPDATE freight.bookings SET status = 'SUBMITTED'
WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
UPDATE freight.bookings SET status = 'REJECTED'
WHERE status = 'QUOTATION_REJECTED';
UPDATE freight.bookings SET status = 'CANCELLED'
WHERE status = 'CANCELLED';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS locked_at,
DROP COLUMN IF EXISTS contract_summary,
DROP COLUMN IF EXISTS marketing_approved_at,
DROP COLUMN IF EXISTS marketing_approved_by_id;
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
}
}

View File

@@ -0,0 +1,115 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm';
export class CreateCompaniesModule1749200000000 implements MigrationInterface {
name = 'CreateCompaniesModule1749200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'companies',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'name', type: 'varchar', length: '200' },
{ name: 'type', type: 'varchar', length: '32' },
{ name: 'status', type: 'varchar', length: '32', default: "'pending'" },
{ name: 'tin', type: 'varchar', length: '10', isUnique: true },
{ name: 'vat_number', type: 'varchar', length: '50', isNullable: true },
{ name: 'business_license', type: 'varchar', length: '100', isNullable: true },
{ name: 'fan_number', type: 'varchar', length: '16', isNullable: true },
{ name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" },
{ name: 'address', type: 'text', isNullable: true },
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
{ name: 'email', type: 'varchar', length: '150', isNullable: true },
{ name: 'website', type: 'varchar', length: '200', isNullable: true },
{ name: 'attributes', type: 'jsonb', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'external_profiles',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'user_id', type: 'uuid' },
{ name: 'company_id', type: 'uuid' },
{ name: 'first_name', type: 'varchar', length: '100' },
{ name: 'last_name', type: 'varchar', length: '100' },
{ name: 'email', type: 'varchar', length: '150', isUnique: true },
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
{ name: 'national_id', type: 'varchar', length: '50', isNullable: true },
{ name: 'job_title', type: 'varchar', length: '100', isNullable: true },
{ name: 'is_primary_contact', type: 'boolean', default: false },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'ff_clients',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'forwarder_company_id', type: 'uuid' },
{ name: 'client_company_id', type: 'uuid' },
{ name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" },
{ name: 'can_book_on_behalf', type: 'boolean', default: true },
{ name: 'can_view_documents', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['forwarder_company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
{
columnNames: ['client_company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
],
}),
true,
);
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] }));
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] }));
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] }));
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] }));
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] }));
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] }));
await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({
columnNames: ['forwarder_company_id', 'client_company_id'],
}));
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.ff_clients');
await queryRunner.dropTable('freight.external_profiles');
await queryRunner.dropTable('freight.companies');
}
}

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingFreightType1749300000000 implements MigrationInterface {
name = 'AddBookingFreightType1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET freight_type = 'CONTAINER'
WHERE EXISTS (
SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET freight_type = 'BULK'
WHERE freight_type IS NULL
AND b.cargo_type_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM freight.cargo_types ct
WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
);
`);
await queryRunner.query(`
UPDATE freight.bookings
SET freight_type = 'CONTAINER'
WHERE freight_type IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN cargo_type_id DROP NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN freight_type SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT chk_bookings_freight_type
CHECK (freight_type IN ('CONTAINER', 'BULK'));
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
`);
await queryRunner.query(`
UPDATE freight.bookings SET cargo_type_id = (
SELECT id FROM freight.cargo_types LIMIT 1
) WHERE cargo_type_id IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN cargo_type_id SET NOT NULL;
`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
name = 'AddFanNumberToCompanies1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// fan_number may already exist when CreateCompaniesModule ran with the full schema
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS fan_number;
`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddContractSignatures1749400000000 implements MigrationInterface {
name = 'AddContractSignatures1749400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
signer_role VARCHAR(20) NOT NULL,
signer_user_id UUID,
signer_display_name VARCHAR(200) NOT NULL,
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
consent_text TEXT,
ip_address VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_booking_contract_signatures_role
UNIQUE (booking_id, signer_role)
);
CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
ON freight.booking_contract_signatures(booking_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS pricing_breakdown,
DROP COLUMN IF EXISTS contract_generated_at,
DROP COLUMN IF EXISTS contract_template_key;
`);
}
}

View File

@@ -0,0 +1,153 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTrainScheduling1749400000000 implements MigrationInterface {
name = 'AddTrainScheduling1749400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
max_wagons_per_train INT NULL,
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.locomotives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NULL,
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
available_from TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
locomotive_id UUID NOT NULL,
total_weight_tons NUMERIC(10,3) NOT NULL,
total_length_meters NUMERIC(10,3) NOT NULL,
wagon_count INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL,
wagon_type_id UUID NOT NULL,
sequence_no INT NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL UNIQUE,
origin_station_id UUID NOT NULL,
destination_station_id UUID NOT NULL,
scheduled_departure_date TIMESTAMPTZ NOT NULL,
scheduled_arrival_date TIMESTAMPTZ NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id),
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
REFERENCES freight.yards(id),
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
REFERENCES freight.yards(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id UUID NOT NULL,
booking_id UUID NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_wagon_id UUID NOT NULL,
booking_id UUID NOT NULL,
allocated_weight_tons NUMERIC(10,3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_status
ON freight.locomotives(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_sets_status
ON freight.train_sets(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
ON freight.train_schedules(scheduled_departure_date, status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
ON freight.wagon_booking_allocations(booking_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
name = 'AddCompanyIdToBookings1749500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN customer_id DROP NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS company_id UUID;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_company_id
ON freight.bookings(company_id);
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
) THEN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_company_id"
FOREIGN KEY (company_id)
REFERENCES freight.companies(id);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
`);
await queryRunner.query(`
UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN customer_id SET NOT NULL;
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_bookings_company_id;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS company_id;
`);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface {
name = 'AddBlocksRoleToApprovalStep1749600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP COLUMN IF EXISTS blocks_role;
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seed ITMLS US-06 approval chains if missing (standard + bulk).
*/
export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface {
name = 'SeedDefaultApprovalRules1749700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL
);
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Keep seeded rules on rollback to avoid breaking in-flight bookings.
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
/**
* shipping_lines was created without a unique index on code; seeder upserts require it.
*/
export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface {
name = 'AddShippingLinesCodeUniqueIndex1749800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`,
);
if (existing.length === 0) {
await queryRunner.createIndex(
'freight.shipping_lines',
new TableIndex({
name: 'UQ_shipping_lines_code',
columnNames: ['code'],
isUnique: true,
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code');
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables.
*/
export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface {
name = 'CreateFileUploadSettingsTables1749900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(128) NOT NULL,
label VARCHAR(256) NOT NULL,
description TEXT,
entity VARCHAR(32) NOT NULL DEFAULT 'other',
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_file_upload_settings_code"
ON freight.file_upload_settings (code);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_fields (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
setting_id UUID NOT NULL,
file_key VARCHAR(128) NOT NULL,
file_label VARCHAR(256) NOT NULL,
help_text TEXT,
is_required BOOLEAN NOT NULL DEFAULT false,
is_multiple BOOLEAN NOT NULL DEFAULT false,
max_files INTEGER NOT NULL DEFAULT 1,
allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[],
max_size_mb INTEGER NOT NULL DEFAULT 10,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0),
CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0),
CONSTRAINT "FK_file_upload_fields_setting"
FOREIGN KEY (setting_id)
REFERENCES freight.file_upload_settings(id)
ON DELETE CASCADE
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key"
ON freight.file_upload_fields (setting_id, file_key);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyContactColumns1750000000000 implements MigrationInterface {
name = 'AddCompanyContactColumns1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_name VARCHAR(100);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_phone VARCHAR(20);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_name VARCHAR(100);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_email VARCHAR(150);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_phone VARCHAR(20);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_phone;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_email;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_name;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_phone;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_name;`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Train entity gained extended fields; baseline trains table only had code/capacity/status/notes.
*/
export class AddTrainExtendedColumns1750000000000 implements MigrationInterface {
name = 'AddTrainExtendedColumns1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.trains
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20),
ADD COLUMN IF NOT EXISTS train_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS route_id UUID,
ADD COLUMN IF NOT EXISTS origin_station_id UUID,
ADD COLUMN IF NOT EXISTS destination_station_id UUID,
ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS remarks TEXT;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number"
ON freight.trains (train_number)
WHERE train_number IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`);
await queryRunner.query(`
ALTER TABLE freight.trains
DROP COLUMN IF EXISTS remarks,
DROP COLUMN IF EXISTS locomotive_number,
DROP COLUMN IF EXISTS arrival_time,
DROP COLUMN IF EXISTS departure_time,
DROP COLUMN IF EXISTS destination_station_id,
DROP COLUMN IF EXISTS origin_station_id,
DROP COLUMN IF EXISTS route_id,
DROP COLUMN IF EXISTS train_name,
DROP COLUMN IF EXISTS train_number;
`);
}
}

View File

@@ -0,0 +1,138 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateFacilitiesTable1750000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'facilities',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{
name: 'code',
type: 'varchar',
length: '40',
isUnique: true,
},
{
name: 'name',
type: 'varchar',
length: '160',
},
{
name: 'description',
type: 'text',
isNullable: true,
},
{
name: 'facility_type',
type: 'varchar',
length: '32',
},
{
name: 'facility_status',
type: 'varchar',
length: '32',
default: "'ACTIVE'",
},
{
name: 'location_name',
type: 'varchar',
length: '200',
isNullable: true,
},
{
name: 'country',
type: 'varchar',
length: '100',
isNullable: true,
},
{
name: 'city',
type: 'varchar',
length: '100',
isNullable: true,
},
{
name: 'address',
type: 'text',
isNullable: true,
},
{
name: 'latitude',
type: 'numeric',
precision: 10,
scale: 8,
isNullable: true,
},
{
name: 'longitude',
type: 'numeric',
precision: 11,
scale: 8,
isNullable: true,
},
{
name: 'capacity',
type: 'numeric',
precision: 14,
scale: 3,
isNullable: true,
},
{
name: 'is_active',
type: 'boolean',
default: true,
},
{
name: 'notes',
type: 'text',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updated_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'deleted_at',
type: 'timestamp',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'freight.facilities',
new TableIndex({
name: 'idx_facilities_code',
columnNames: ['code'],
isUnique: true,
}),
);
await queryRunner.createIndex(
'freight.facilities',
new TableIndex({
name: 'idx_facilities_status',
columnNames: ['facility_status'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.facilities');
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
export class AddFacilityIdToWarehouses1750000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.warehouses');
if (!table) {
// warehouses table doesn't exist yet, skip this migration
return;
}
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
if (hasColumn) {
// Column already exists, skip
return;
}
await queryRunner.addColumn(
'freight.warehouses',
new TableColumn({
name: 'facility_id',
type: 'uuid',
isNullable: true,
}),
);
await queryRunner.createForeignKey(
'freight.warehouses',
new TableForeignKey({
columnNames: ['facility_id'],
referencedColumnNames: ['id'],
referencedTableName: 'facilities',
referencedSchema: 'freight',
onDelete: 'SET NULL',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.warehouses');
if (!table) {
return;
}
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
if (foreignKey) {
await queryRunner.dropForeignKey('freight.warehouses', foreignKey);
}
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
if (hasColumn) {
await queryRunner.dropColumn('freight.warehouses', 'facility_id');
}
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Proof of Delivery (customer pickup) capture on cargoes:
* receiver name, delivered/picked-up timestamp, and delivery remarks.
*/
export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.cargoes');
if (!table) {
// cargoes table doesn't exist yet, skip this migration
return;
}
const columnsToAdd = [
{ name: 'receiver_name', type: 'varchar', isNullable: true },
{ name: 'delivered_at', type: 'timestamp', isNullable: true },
{ name: 'delivery_remarks', type: 'text', isNullable: true },
];
const columnsToCreate = columnsToAdd.filter(
(col) => !table.columns.some((c) => c.name === col.name),
);
if (columnsToCreate.length > 0) {
await queryRunner.addColumns(
'freight.cargoes',
columnsToCreate.map((col) => new TableColumn(col)),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.cargoes');
if (!table) {
return;
}
const columnNames = ['receiver_name', 'delivered_at', 'delivery_remarks'];
const columnsToRemove = columnNames.filter((name) =>
table.columns.some((c) => c.name === name),
);
if (columnsToRemove.length > 0) {
await queryRunner.dropColumns('freight.cargoes', columnsToRemove);
}
}
}

View File

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
/**
* Batch 4.5 — warehouse inspection reports + inventory inspection status.
*/
export class AddWarehouseInspection1750000000003 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// inventory.inspection_status
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
if (!hasColumn) {
await queryRunner.addColumn(
'freight.warehouse_inventory',
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
);
}
}
// warehouse_inspection_reports table
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
if (!inspectionTable) {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_inspection_reports',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'inventory_id', type: 'uuid' },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'customer_id', type: 'uuid', isNullable: true },
{ name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" },
{ name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" },
{ name: 'has_damage', type: 'boolean', default: false },
{ name: 'damage_description', type: 'text', isNullable: true },
{ name: 'has_weight_loss', type: 'boolean', default: false },
{ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true },
{ name: 'has_missing_items', type: 'boolean', default: false },
{ name: 'missing_items_description', type: 'text', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'inspected_by_id', type: 'uuid', isNullable: true },
{ name: 'inspected_at', type: 'timestamptz', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_wir_inventory', columnNames: ['inventory_id'] },
{ name: 'idx_wir_booking', columnNames: ['booking_id'] },
{ name: 'idx_wir_status', columnNames: ['inspection_status'] },
],
}),
true,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
if (inspectionTable) {
await queryRunner.dropTable('freight.warehouse_inspection_reports', true);
}
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
if (hasColumn) {
await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status');
}
}
}
}

View File

@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface {
name = 'AddRoutesAndExtendLocomotives1750100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL',
ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760,
ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL;
`);
await queryRunner.query(`
UPDATE freight.locomotives
SET status = 'OUT_OF_SERVICE'
WHERE status = 'INACTIVE';
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(120) NOT NULL UNIQUE,
origin_yard_id UUID NOT NULL REFERENCES freight.yards(id),
destination_yard_id UUID NOT NULL REFERENCES freight.yards(id),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.route_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE,
yard_id UUID NOT NULL REFERENCES freight.yards(id),
sequence_no INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id
ON freight.routes(origin_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id
ON freight.routes(destination_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_is_active
ON freight.routes(is_active);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id
ON freight.route_milestones(route_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id
ON freight.route_milestones(yard_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS max_speed_kmh,
DROP COLUMN IF EXISTS traction_force_kn,
DROP COLUMN IF EXISTS power_kw,
DROP COLUMN IF EXISTS max_train_length_meters,
DROP COLUMN IF EXISTS locomotive_type;
`);
await queryRunner.query(`
UPDATE freight.locomotives
SET status = 'INACTIVE'
WHERE status = 'OUT_OF_SERVICE';
`);
}
}

View File

@@ -0,0 +1,127 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateFleetCrudTables1750100000000 implements MigrationInterface {
name = 'CreateFleetCrudTables1750100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
wagon_number VARCHAR NOT NULL UNIQUE,
wagon_type_id UUID NOT NULL,
train_id UUID,
sequence_number INT,
tare_weight NUMERIC(10, 2) NOT NULL,
max_payload_weight NUMERIC(10, 2) NOT NULL,
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.containers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
container_number VARCHAR NOT NULL UNIQUE,
container_type_id UUID NOT NULL,
wagon_id UUID,
position INT,
tare_weight NUMERIC(10, 2) NOT NULL,
max_gross_weight NUMERIC(10, 2) NOT NULL,
seal_number VARCHAR,
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.cargoes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cargo_reference VARCHAR NOT NULL UNIQUE,
shipment_id UUID NOT NULL,
container_id UUID NOT NULL,
cargo_type_id UUID,
description TEXT,
quantity NUMERIC(12, 3) NOT NULL,
weight NUMERIC(10, 2) NOT NULL,
volume NUMERIC(10, 2),
status VARCHAR NOT NULL DEFAULT 'PENDING',
loaded_at TIMESTAMP,
unloaded_at TIMESTAMP,
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_wagons_train_id" ON freight.wagons (train_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_containers_wagon_id" ON freight.containers (wagon_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_cargoes_container_id" ON freight.cargoes (container_id)`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagons_train_id"
FOREIGN KEY (train_id) REFERENCES freight.trains(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagons_wagon_type_id"
FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT "FK_containers_wagon_id"
FOREIGN KEY (wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT "FK_containers_container_type_id"
FOREIGN KEY (container_type_id) REFERENCES freight.container_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT "FK_cargoes_container_id"
FOREIGN KEY (container_id) REFERENCES freight.containers(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT "FK_cargoes_cargo_type_id"
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargoes CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.containers CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagons CASCADE`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPhysicalWagonToTrainSetWagons1750200000000 implements MigrationInterface {
name = 'AddPhysicalWagonToTrainSetWagons1750200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_schema = 'freight'
AND table_name = 'train_set_wagons'
AND constraint_name = 'fk_train_set_wagons_physical_wagon'
) THEN
ALTER TABLE freight.train_set_wagons
ADD CONSTRAINT fk_train_set_wagons_physical_wagon
FOREIGN KEY (physical_wagon_id)
REFERENCES freight.wagons(id)
ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
ON freight.train_set_wagons(physical_wagon_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_set_wagons_physical_wagon;`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
DROP CONSTRAINT IF EXISTS fk_train_set_wagons_physical_wagon;
`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
DROP COLUMN IF EXISTS physical_wagon_id;
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeedDefaultWagonTypes1750200000000 implements MigrationInterface {
name = 'SeedDefaultWagonTypes1750200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.wagon_types (
code,
name,
capacity_tons,
length_meters,
max_wagons_per_train,
supported_load_types,
is_active
)
VALUES
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true),
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true),
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true),
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true),
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true),
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true),
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true),
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true),
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true),
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
supported_load_types = EXCLUDED.supported_load_types,
is_active = true,
deleted_at = NULL,
updated_at = now();
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.wagon_types
WHERE code IN ('NW7', 'NW5', 'PW2', 'GW2', 'CW4', 'CW3', 'KW2', 'KW3', 'NW6', 'BW1');
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCurrentLocationToWagons1750300000000 implements MigrationInterface {
name = 'AddCurrentLocationToWagons1750300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS current_location_yard_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_schema = 'freight'
AND table_name = 'wagons'
AND constraint_name = 'FK_wagons_current_location_yard_id'
) THEN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagons_current_location_yard_id"
FOREIGN KEY (current_location_yard_id)
REFERENCES freight.yards(id)
ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_wagons_current_location_yard_id"
ON freight.wagons(current_location_yard_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagons_current_location_yard_id";`);
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP CONSTRAINT IF EXISTS "FK_wagons_current_location_yard_id";
`);
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS current_location_yard_id;
`);
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
name = 'AddRouteToTrainSchedules1750300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS route_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_train_schedules_route'
) THEN
ALTER TABLE freight.train_schedules
ADD CONSTRAINT fk_train_schedules_route
FOREIGN KEY (route_id) REFERENCES freight.routes(id);
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id
ON freight.train_schedules(route_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP CONSTRAINT IF EXISTS fk_train_schedules_route;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS route_id;
`);
}
}

View File

@@ -0,0 +1,321 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSchedulingAllocationEnhancements1750400000000
implements MigrationInterface
{
name = 'AddSchedulingAllocationEnhancements1750400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS wagons_required NUMERIC(6,2) NULL,
ADD COLUMN IF NOT EXISTS scheduling_status VARCHAR(30) NOT NULL DEFAULT 'NOT_SCHEDULED',
ADD COLUMN IF NOT EXISTS hold_started_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS hold_expires_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20) NULL,
ADD COLUMN IF NOT EXISTS direction VARCHAR(10) NULL,
ADD COLUMN IF NOT EXISTS actual_departure_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS actual_arrival_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS prepared_by_user_id UUID NULL,
ADD COLUMN IF NOT EXISTS checked_by_user_id UUID NULL,
ADD COLUMN IF NOT EXISTS max_wagons INT NOT NULL DEFAULT 53;
`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL,
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED';
`);
await queryRunner.query(`
ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL,
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED',
ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS confirmed_by_user_id UUID NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.wagon_types
ADD COLUMN IF NOT EXISTS equated_length_m NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS tare_weight_tons NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS supports_container BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS max_container_gross_t NUMERIC(10,3) NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS train_set_wagon_id UUID NULL,
ADD COLUMN IF NOT EXISTS current_train_schedule_id UUID NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.containers
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
ADD COLUMN IF NOT EXISTS booking_container_id UUID NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.cargoes
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.cargoes
ALTER COLUMN container_id DROP NOT NULL;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_container_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wagon_booking_allocation_id UUID NOT NULL,
booking_container_id UUID NULL,
container_id UUID NULL,
container_number VARCHAR(64) NULL,
container_type_id UUID NOT NULL,
position_on_wagon SMALLINT NULL,
seal_number VARCHAR(64) NULL,
chassis_number VARCHAR(64) NULL,
gross_weight_tons NUMERIC(10,3) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_waci_allocation FOREIGN KEY (wagon_booking_allocation_id)
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
CONSTRAINT fk_waci_booking_container FOREIGN KEY (booking_container_id)
REFERENCES freight.booking_container(id) ON DELETE SET NULL,
CONSTRAINT fk_waci_container FOREIGN KEY (container_id)
REFERENCES freight.containers(id) ON DELETE SET NULL,
CONSTRAINT fk_waci_container_type FOREIGN KEY (container_type_id)
REFERENCES freight.container_types(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_bulk_loads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wagon_booking_allocation_id UUID NOT NULL UNIQUE,
booking_id UUID NOT NULL,
cargo_type_id UUID NULL,
cargo_description TEXT NULL,
pricing_unit VARCHAR(20) NOT NULL DEFAULT 'PER_TON',
quantity NUMERIC(12,3) NOT NULL DEFAULT 0,
weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
truck_plate_number VARCHAR(32) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_wabl_allocation FOREIGN KEY (wagon_booking_allocation_id)
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
CONSTRAINT fk_wabl_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id),
CONSTRAINT fk_wabl_cargo_type FOREIGN KEY (cargo_type_id)
REFERENCES freight.cargo_types(id) ON DELETE SET NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_scheduling_status
ON freight.bookings(scheduling_status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_train_number
ON freight.train_schedules(train_number);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
ON freight.train_set_wagons(physical_wagon_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_train_set_wagon_id
ON freight.wagons(train_set_wagon_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_current_train_schedule_id
ON freight.wagons(current_train_schedule_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_waci_allocation
ON freight.wagon_allocation_container_items(wagon_booking_allocation_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wabl_booking
ON freight.wagon_allocation_bulk_loads(booking_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.train_set_wagons
ADD CONSTRAINT fk_train_set_wagons_physical_wagon
FOREIGN KEY (physical_wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.wagons
ADD CONSTRAINT fk_wagons_train_set_wagon
FOREIGN KEY (train_set_wagon_id) REFERENCES freight.train_set_wagons(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.wagons
ADD CONSTRAINT fk_wagons_current_train_schedule
FOREIGN KEY (current_train_schedule_id) REFERENCES freight.train_schedules(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT fk_containers_booking
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT fk_containers_wagon_allocation
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT fk_containers_booking_container
FOREIGN KEY (booking_container_id) REFERENCES freight.booking_container(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT fk_cargoes_wagon_allocation
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT fk_cargoes_booking
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
UPDATE freight.wagon_types SET
equated_length_m = 1.3,
tare_weight_tons = 22.4,
supports_container = true,
max_container_gross_t = 30.48
WHERE code = 'NW5';
`);
await queryRunner.query(`
UPDATE freight.wagon_types SET
equated_length_m = 1.6,
tare_weight_tons = 25.2,
supports_container = false
WHERE code = 'PW2';
`);
await queryRunner.query(`
UPDATE freight.wagon_types SET
equated_length_m = 1.5,
tare_weight_tons = 25.2,
supports_container = false
WHERE code = 'KW2';
`);
await queryRunner.query(`
UPDATE freight.wagon_types SET
equated_length_m = 1.3,
tare_weight_tons = 23.4,
supports_container = false
WHERE code = 'CW3';
`);
await queryRunner.query(`
UPDATE freight.wagon_types SET
equated_length_m = 1.3,
tare_weight_tons = 24.8,
supports_container = false
WHERE code = 'CW4';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_bulk_loads;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_container_items;`);
await queryRunner.query(`
ALTER TABLE freight.cargoes
ALTER COLUMN container_id SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS wagons_required,
DROP COLUMN IF EXISTS scheduling_status,
DROP COLUMN IF EXISTS hold_started_at,
DROP COLUMN IF EXISTS hold_expires_at,
DROP COLUMN IF EXISTS scheduled_at;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS train_number,
DROP COLUMN IF EXISTS direction,
DROP COLUMN IF EXISTS actual_departure_at,
DROP COLUMN IF EXISTS actual_arrival_at,
DROP COLUMN IF EXISTS prepared_by_user_id,
DROP COLUMN IF EXISTS checked_by_user_id,
DROP COLUMN IF EXISTS max_wagons;
`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
DROP COLUMN IF EXISTS physical_wagon_id,
DROP COLUMN IF EXISTS status;
`);
await queryRunner.query(`
ALTER TABLE freight.wagon_booking_allocations
DROP COLUMN IF EXISTS load_type,
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS confirmed_at,
DROP COLUMN IF EXISTS confirmed_by_user_id;
`);
await queryRunner.query(`
ALTER TABLE freight.wagon_types
DROP COLUMN IF EXISTS equated_length_m,
DROP COLUMN IF EXISTS tare_weight_tons,
DROP COLUMN IF EXISTS supports_container,
DROP COLUMN IF EXISTS max_container_gross_t;
`);
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS train_set_wagon_id,
DROP COLUMN IF EXISTS current_train_schedule_id;
`);
await queryRunner.query(`
ALTER TABLE freight.containers
DROP COLUMN IF EXISTS booking_id,
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
DROP COLUMN IF EXISTS booking_container_id;
`);
await queryRunner.query(`
ALTER TABLE freight.cargoes
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
DROP COLUMN IF EXISTS booking_id,
DROP COLUMN IF EXISTS load_type;
`);
}
}

View File

@@ -0,0 +1,214 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type FleetRow = {
code: string;
name: string;
count: number;
start: number;
end: number;
capacityTons: number;
tareWeight: number;
lengthMeters: number;
supportedLoadTypes: string[];
};
const FLEET: FleetRow[] = [
{
code: 'PW2',
name: 'Box wagon',
count: 220,
start: 1,
end: 220,
capacityTons: 70,
tareWeight: 25.2,
lengthMeters: 17.066,
supportedLoadTypes: ['BULK', 'GENERAL_CARGO', 'BAGGED_CARGO', 'BOXED_CARGO'],
},
{
code: 'CW4',
name: 'Gondola wagon covered',
count: 110,
start: 221,
end: 330,
capacityTons: 70,
tareWeight: 24.8,
lengthMeters: 13.976,
supportedLoadTypes: ['CONTAINER'],
},
{
code: 'CW3',
name: 'Gondola wagon',
count: 20,
start: 331,
end: 350,
capacityTons: 70,
tareWeight: 23.4,
lengthMeters: 13.976,
supportedLoadTypes: ['BULK', 'COAL', 'ORE'],
},
{
code: 'KW2',
name: 'Hopper wagon covered',
count: 20,
start: 351,
end: 370,
capacityTons: 69,
tareWeight: 25.2,
lengthMeters: 16.466,
supportedLoadTypes: ['BULK', 'GRAIN'],
},
{
code: 'KW3',
name: 'Hopper wagon',
count: 20,
start: 371,
end: 390,
capacityTons: 70,
tareWeight: 24,
lengthMeters: 14.4,
supportedLoadTypes: ['BULK', 'COAL'],
},
{
code: 'NW5',
name: 'Flat wagon container',
count: 550,
start: 391,
end: 940,
capacityTons: 70,
tareWeight: 0,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
},
];
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
export class SeedEdRWagonFleet1750400000000 implements MigrationInterface {
name = 'SeedEdRWagonFleet1750400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagon_types
SET name = 'Flat wagon container',
capacity_tons = 70,
length_meters = 14.000,
supported_load_types = ARRAY['CONTAINER'],
max_wagons_per_train = 53,
is_active = true,
deleted_at = NULL,
updated_at = now()
WHERE code = 'NW5';
`);
const [defaultLocation] = await queryRunner.query(`
SELECT id
FROM freight.yards
WHERE code IN ('DJIBOUTI', 'DJIB_PORT', 'NAGAD')
OR lower(label) LIKE '%djibouti%'
ORDER BY
CASE code
WHEN 'DJIBOUTI' THEN 1
WHEN 'DJIB_PORT' THEN 2
WHEN 'NAGAD' THEN 3
ELSE 4
END,
display_order ASC
LIMIT 1;
`);
const defaultLocationYardId = defaultLocation?.id ?? null;
for (const row of FLEET) {
await queryRunner.query(
`
INSERT INTO freight.wagon_types (
code,
name,
capacity_tons,
length_meters,
max_wagons_per_train,
supported_load_types,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6::text[], true)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
supported_load_types = EXCLUDED.supported_load_types,
is_active = true,
deleted_at = NULL,
updated_at = now();
`,
[
row.code,
row.name,
row.capacityTons,
row.lengthMeters,
row.supportedLoadTypes.includes('CONTAINER') ? 53 : 37,
row.supportedLoadTypes,
],
);
const [typeRecord] = await queryRunner.query(
`SELECT id FROM freight.wagon_types WHERE code = $1 LIMIT 1;`,
[row.code],
);
if (!typeRecord?.id) {
throw new Error(`wagon_type_seed_failed:${row.code}`);
}
if (row.end - row.start + 1 !== row.count) {
throw new Error(`wagon_range_mismatch:${row.code}`);
}
for (let sequence = row.start; sequence <= row.end; sequence += 1) {
await queryRunner.query(
`
INSERT INTO freight.wagons (
wagon_number,
wagon_type_id,
tare_weight,
max_payload_weight,
current_location_yard_id,
status,
notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (wagon_number) DO UPDATE SET
wagon_type_id = EXCLUDED.wagon_type_id,
tare_weight = EXCLUDED.tare_weight,
max_payload_weight = EXCLUDED.max_payload_weight,
current_location_yard_id = CASE
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.current_location_yard_id
ELSE freight.wagons.current_location_yard_id
END,
status = CASE
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.status
ELSE freight.wagons.status
END,
notes = EXCLUDED.notes,
updated_at = now();
`,
[
wagonNumber(sequence),
typeRecord.id,
row.tareWeight,
row.capacityTons,
defaultLocationYardId,
defaultLocationYardId ? 'IMPORT_READY' : 'AVAILABLE',
`Seeded Ethio-Djibouti Railway ${row.code} fleet record.`,
],
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.wagons
WHERE wagon_number BETWEEN 'ER0001' AND 'ER0940';
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWagonReadiness1750500000000 implements MigrationInterface {
name = 'AddWagonReadiness1750500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
ON freight.wagons (readiness)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS readiness
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddGovernmentBookingFields1750600000000 implements MigrationInterface {
name = 'AddGovernmentBookingFields1750600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS is_government BOOLEAN NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS government_institution VARCHAR(255) NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN company_id DROP NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_is_government
ON freight.bookings (is_government)
WHERE is_government = true AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_is_government`);
await queryRunner.query(`
UPDATE freight.bookings
SET company_id = '00000000-0000-0000-0000-000000000000'
WHERE company_id IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN company_id SET NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS government_institution
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS is_government
`);
}
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSchedulingEvents1750700000000 implements MigrationInterface {
name = 'CreateSchedulingEvents1750700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.scheduling_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id UUID NOT NULL,
trigger VARCHAR(40) NOT NULL,
actor_user_id UUID NULL,
reason TEXT NULL,
plan_snapshot JSONB NOT NULL DEFAULT '{}',
displaced_booking_ids JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_scheduling_events_train_schedule_id
ON freight.scheduling_events (train_schedule_id)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** 20ft = 0.5 wagon slots (2 per wagon); 40ft = 1.0 wagon slot (1 per wagon). */
export class FixContainerWagonsPerUnit1750800000000 implements MigrationInterface {
name = 'FixContainerWagonsPerUnit1750800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
if (!hasContainerTypes) {
return;
}
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = 0.50
WHERE size_ft = 20 OR code LIKE '20%';
`);
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = 1.00
WHERE size_ft = 40 OR code LIKE '40%';
`);
const hasBookingContainer = await queryRunner.hasTable('freight.booking_container');
if (!hasBookingContainer) {
return;
}
await queryRunner.query(`
UPDATE freight.booking_container bc
SET wagons_required = CEILING(bc.quantity * ct.wagons_per_unit)
FROM freight.container_types ct
WHERE ct.id = bc.container_type_id;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
if (!hasContainerTypes) {
return;
}
await queryRunner.query(`
UPDATE freight.container_types SET wagons_per_unit = 1.00;
`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface {
name = "AddContainerNumberToBookingContainer1750900000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_container
ALTER COLUMN container_type_id DROP NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
ADD COLUMN container_number varchar(64);
`);
await queryRunner.query(`
ALTER TABLE freight.wagon_allocation_container_items
ALTER COLUMN container_type_id DROP NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_allocation_container_items
ALTER COLUMN container_type_id SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP COLUMN container_number;
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
ALTER COLUMN container_type_id SET NOT NULL;
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface {
name = "CreateTrainSchedulingGlobalRules1751000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE freight.train_scheduling_global_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
max_train_length_meters numeric(10, 2) NOT NULL DEFAULT 760,
max_train_weight_tons numeric(10, 3) NOT NULL DEFAULT 3500,
max_wagons_per_train integer NOT NULL DEFAULT 53,
max_20ft_container_weight_tons numeric(8, 3) NOT NULL DEFAULT 30,
max_20ft_pair_weight_diff_tons numeric(8, 3) NOT NULL DEFAULT 10,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
`);
await queryRunner.query(`
INSERT INTO freight.train_scheduling_global_rules (
max_train_length_meters,
max_train_weight_tons,
max_wagons_per_train,
max_20ft_container_weight_tons,
max_20ft_pair_weight_diff_tons
) VALUES (760, 3500, 53, 30, 10);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001
implements MigrationInterface
{
name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS deleted_at;
`);
}
}

View File

@@ -0,0 +1,103 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
} from "typeorm";
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
name = "CreateCompanyProfiles1752000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: "freight",
name: "company_profiles",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "gen_random_uuid()",
},
{ name: "company_id", type: "uuid" },
{ name: "type", type: "varchar", length: "32" },
{ name: "reference", type: "varchar", length: "20", isUnique: true },
{
name: "status",
type: "varchar",
length: "32",
default: "'active'",
},
{
name: "business_license",
type: "varchar",
length: "100",
isNullable: true,
},
{ name: "attributes", type: "jsonb", isNullable: true },
{ name: "created_at", type: "timestamptz", default: "now()" },
{ name: "updated_at", type: "timestamptz", default: "now()" },
{ name: "deleted_at", type: "timestamptz", isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
"freight.company_profiles",
new TableForeignKey({
columnNames: ["company_id"],
referencedTableName: "companies",
referencedSchema: "freight",
referencedColumnNames: ["id"],
}),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["company_id"] }),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["type"] }),
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("freight.company_profiles");
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveBusinessLicenseToProfile1752000000001
implements MigrationInterface
{
name = 'MoveBusinessLicenseToProfile1752000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.company_profiles cp
SET business_license = c.business_license
FROM freight.companies c
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
`);
await queryRunner.query(
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
);
await queryRunner.query(`
UPDATE freight.companies c
SET business_license = cp.business_license
FROM (
SELECT DISTINCT ON (cp2.company_id)
cp2.company_id, cp2.business_license
FROM freight.company_profiles cp2
WHERE cp2.business_license IS NOT NULL
ORDER BY cp2.company_id, cp2.created_at
) cp
WHERE cp.company_id = c.id
`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
CREATE TABLE freight.vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plate_number VARCHAR NOT NULL UNIQUE,
registration_number VARCHAR NOT NULL UNIQUE,
vehicle_type VARCHAR NOT NULL,
manufacturer VARCHAR NOT NULL,
model VARCHAR NOT NULL,
year INTEGER NOT NULL,
fuel_type VARCHAR NOT NULL,
capacity NUMERIC NOT NULL,
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
);
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
}
}

View File

@@ -0,0 +1,98 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreatePaymentTable1780639311366 implements MigrationInterface {
name = "CreatePaymentTable1780639311366";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.payments_type_enum AS ENUM ('booking');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_status_enum AS ENUM (
'action-required',
'processing',
'success',
'failed',
'canceled',
'refunded'
);
`);
await queryRunner.query(`
CREATE TABLE freight.payments (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
ref_id varchar(255) NOT NULL,
type freight.payments_type_enum NOT NULL,
method freight.payments_method_enum NOT NULL,
currency freight.payments_currency_enum NOT NULL,
amount numeric NOT NULL,
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
client_action json,
merchant_order_id varchar(255) NOT NULL,
transaction_id varchar(255),
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
paid_at date,
refunded_at date,
expires_at date,
failer_code varchar(30),
failer_message varchar(255),
reason varchar(255),
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT PK_payments PRIMARY KEY (id),
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TABLE IF EXISTS freight.payments;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_status_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_currency_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_method_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_type_enum;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AlterClientActionToJsonb1780639978834 implements MigrationInterface {
name = "AlterClientActionToJsonb1780639978834";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE jsonb
USING client_action::jsonb;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action DROP DEFAULT;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE json
USING client_action::json;
`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface {
name = "UpdatePaymentTimestamp1780644945086";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamp
USING refunded_at::timestamp;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamp
USING expires_at::timestamp;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamptz
USING refunded_at::timestamptz;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamptz
USING expires_at::timestamptz;
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLocomotiveReadiness1781000000000 implements MigrationInterface {
name = 'AddLocomotiveReadiness1781000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
ON freight.locomotives (readiness)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS readiness
`);
}
}

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