Merge branch 'feat/payment-microservice' of github.com:Tria-plc/edr-platform into freight_feature/payments

This commit is contained in:
marshal
2026-06-14 02:38:19 +03:00
192 changed files with 15254 additions and 5671 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

@@ -6,18 +6,6 @@ on:
- 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:
@@ -50,6 +38,9 @@ jobs:
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-backoffice
- project: edr-payment
build_env_file: payment-web.build.env
service: payment-api
env:
PROJECT: ${{ matrix.project }}
BRANCH: ${{ github.ref_name }}

243
.github/workflows/polinrider-scan.yml vendored Normal file
View File

@@ -0,0 +1,243 @@
name: PolinRider Malware Scan
# ── Triggers ──────────────────────────────────────────────────────────────────
# Runs on every push and every PR targeting main/master/develop.
# Also available as a manual trigger (workflow_dispatch) and on a nightly
# schedule so dormant infections in older branches are caught too.
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
schedule:
# Nightly full-repo scan at 02:00 UTC
- cron: "0 2 * * *"
workflow_dispatch:
# ── Permissions ───────────────────────────────────────────────────────────────
permissions:
contents: read # checkout
security-events: write # upload SARIF to GitHub Security tab
actions: read
checks: write # annotate PRs with scan findings
# ── Deployment gate ───────────────────────────────────────────────────────────
# All other jobs (build, test, deploy) should list this job under `needs:`.
# If this job fails (exit code 1 from the scanner), the whole workflow stops.
jobs:
polinrider-scan:
name: "PolinRider / Famous Chollima Scan"
runs-on: ubuntu-latest
# Prevent CI from being disabled by any workflow override
if: always()
steps:
# ── 1. Checkout full history ─────────────────────────────────────────────
# Full depth so we can inspect recent commits for temp_auto_push.bat traces
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# ── 2. Detect suspicious force-push patterns in git history ──────────────
- name: Check git history for force-push and timestamp manipulation
id: git-check
shell: bash
run: |
echo "=== Checking for suspicious git history patterns ==="
# Check for .gitignore entries hiding known malware artifacts
GITIGNORE_HITS=0
if [ -f .gitignore ]; then
for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do
if grep -qF "$pattern" .gitignore 2>/dev/null; then
echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern"
GITIGNORE_HITS=$((GITIGNORE_HITS + 1))
fi
done
fi
# Check if malware persistence artifacts exist anywhere in the tree
ARTIFACTS_FOUND=0
for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do
FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null)
if [ -n "$FOUND" ]; then
echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact"
echo "$FOUND"
ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1))
fi
done
# Scan recent commit messages for --no-verify (used by temp_auto_push.bat)
NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true)
if [ -n "$NO_VERIFY_COMMITS" ]; then
echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):"
echo "$NO_VERIFY_COMMITS"
fi
# Check for .woff2 files with unusually large sizes (>50KB is suspicious)
find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do
SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0)
echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload"
done
echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT"
echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT"
# ── 3. Run the JavaScript malware scanner ────────────────────────────────
- name: Run PolinRider malware scanner
id: scanner
shell: bash
run: |
echo "=== Running PolinRider IOC scanner ==="
# The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest)
node .github/scripts/scan.js \
--json \
--output scan-report.json \
.
SCANNER_EXIT=$?
echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT"
# Also emit a human-readable summary to the Actions log
node .github/scripts/scan.js . || true
exit $SCANNER_EXIT
# ── 4. Upload scan report as artifact ────────────────────────────────────
# - name: Upload scan report
# if: always()
# uses: actions/upload-artifact@v4
# with:
# name: polinrider-scan-report
# path: scan-report.json
# retention-days: 90
# # ── 5. Convert to SARIF and upload to GitHub Security tab ─────────────
# - name: Convert scan results to SARIF
# if: always()
# shell: bash
# run: |
# node - << 'SCRIPT'
# const fs = require('fs');
# let report;
# try {
# report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8'));
# } catch {
# // No report = no findings, write empty SARIF
# report = { results: [] };
# }
# const severityMap = {
# CRITICAL: 'error',
# HIGH: 'warning',
# MEDIUM: 'note',
# };
# const sarif = {
# version: '2.1.0',
# $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
# runs: [{
# tool: {
# driver: {
# name: 'PolinRider Malware Scanner',
# version: '1.0.0',
# informationUri: 'https://github.com/your-org/your-repo',
# rules: [
# { id: 'POLINRIDER-001', name: 'StringShufflerVariable',
# shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' },
# helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' },
# { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment',
# shortDescription: { text: "global['!'] campaign marker" } },
# { id: 'POLINRIDER-003', name: 'ShufflerSeedString',
# shortDescription: { text: 'rmcej%otb% seed string' } },
# { id: 'POLINRIDER-004', name: 'KnownC2IP',
# shortDescription: { text: 'Known PolinRider C2 IP address' } },
# { id: 'POLINRIDER-005', name: 'TRONWallet',
# shortDescription: { text: 'Known TRON dead-drop wallet' } },
# { id: 'POLINRIDER-006', name: 'AptosAddress',
# shortDescription: { text: 'Known Aptos dead-drop address' } },
# { id: 'POLINRIDER-007', name: 'XORKey',
# shortDescription: { text: 'Known XOR decryption key' } },
# { id: 'POLINRIDER-008', name: 'KnownMalwareHash',
# shortDescription: { text: 'SHA-256 matches known malware sample' } },
# { id: 'POLINRIDER-009', name: 'BlockchainC2Contact',
# shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } },
# { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn',
# shortDescription: { text: 'windowsHide:true hidden process spawn' } },
# { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire',
# shortDescription: { text: 'Duplicate createRequire injection' } },
# { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding',
# shortDescription: { text: 'Hidden payload via horizontal whitespace' } },
# { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly',
# shortDescription: { text: 'Config file size anomaly' } },
# { id: 'POLINRIDER-014', name: 'PersistenceArtifact',
# shortDescription: { text: 'PolinRider persistence artifact present' } },
# { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern',
# shortDescription: { text: 'Numeric campaign marker pattern' } },
# { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction',
# shortDescription: { text: 'sfL obfuscation function' } },
# { id: 'POLINRIDER-017', name: 'GlobalRequireInjection',
# shortDescription: { text: 'global require/module injection' } },
# ],
# },
# },
# results: (report.results || []).flatMap(file =>
# (file.findings || []).map(finding => ({
# ruleId: finding.id,
# level: severityMap[finding.severity] || 'warning',
# message: { text: finding.description + ' — ' + finding.matches.join('; ') },
# locations: [{
# physicalLocation: {
# artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' },
# region: { startLine: 1 },
# },
# }],
# }))
# ),
# }],
# };
# fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2));
# console.log('SARIF written.');
# SCRIPT
# - name: Upload SARIF to GitHub Security tab
# if: always()
# uses: github/codeql-action/upload-sarif@v3
# with:
# sarif_file: scan-results.sarif
# category: polinrider-malware-scan
# ── 6. Block deployment if infected ──────────────────────────────────────
- name: Enforce clean-scan gate
if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0'
shell: bash
run: |
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║"
echo "║ ║"
echo "║ This repository contains code signatures consistent with the ║"
echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║"
echo "║ ║"
echo "║ DO NOT run npm install, build, or deploy until remediated. ║"
echo "║ ║"
echo "║ See scan-report.json artifact for full details. ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
exit 1
# ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ─────
# Example: your existing build/deploy jobs should look like this:
#
# build:
# needs: polinrider-scan
# runs-on: ubuntu-latest
# steps:
# ...
#
# deploy:
# needs: [polinrider-scan, build]
# ...

3
.gitignore vendored
View File

@@ -23,9 +23,6 @@ coverage/
.idea/
.vscode/
.npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
# emacs cache files
*~

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`

View File

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

View File

@@ -1,6 +1,6 @@
# App
NODE_ENV=development
PORT=3002
PORT=4000
# Database (Prisma)
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
@@ -65,13 +65,25 @@ CARD_WEBHOOK_SECRET=
CARD_WEBHOOK_URL=
CARD_RETURN_URL=
# Waafi (Djibouti Mobile Money)
WAAFI_BASE_URL=https://api.waafipay.net
# Waafi (Djibouti Mobile Money — Hosted Payment Page)
# Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net
WAAFI_BASE_URL=https://sandbox.waafipay.net
WAAFI_MERCHANT_UID=
WAAFI_API_USER_ID=
WAAFI_API_KEY=
WAAFI_STORE_ID=
WAAFI_HPP_KEY=
# HMAC secret returned once by WEBHOOK_REGISTER — verifies inbound webhooks
WAAFI_WEBHOOK_SECRET=
WAAFI_PAYMENT_METHOD=MWALLET_ACCOUNT
# Waafi has no ETB; overrides booking currency (USD/DJF/SLSH)
WAAFI_CURRENCY=DJF
WAAFI_HPP_SUCCESS_URL=
WAAFI_HPP_FAILURE_URL=
# 1 = POST, 2 = GET, 4 = Result Token
WAAFI_HPP_RESP_FORMAT=1
# Registered webhook URL (registration done out-of-band)
WAAFI_NOTIFY_URL=
WAAFI_RETURN_URL=
# DEV ONLY — disable TLS cert verification (sandbox serves a *.waafi.com cert). Never true in prod.
WAAFI_INSECURE_TLS=false
# Payment Configuration
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
@@ -104,3 +116,12 @@ FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are
# independent. Points at the dedicated `payment` vhost on the (shared) broker.
# Local dev broker (docker): amqp://edr:edr_secret@localhost:5672/payment
PAYMENT_RABBITMQ_URL=amqp://edr:edr_secret@localhost:5672/payment
# Max unacknowledged payment events this consumer holds at once.
PAYMENT_EVENTS_PREFETCH=10

View File

@@ -12,18 +12,17 @@
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed-complete.ts",
"prisma:migrate": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"prisma:seed-full": "ts-node prisma/seed.ts",
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
"prisma:verify": "ts-node prisma/verify-backfill.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@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.4",
@@ -70,6 +69,9 @@
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"prisma": {
"schema": "prisma/schema.prisma"
},
"jest": {
"moduleFileExtensions": [
"js",

View File

@@ -1,5 +0,0 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI';
-- AlterTable
ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT;

View File

@@ -1,28 +0,0 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- CreateTable
CREATE TABLE "SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");

View File

@@ -1,55 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
ADD COLUMN "faydaVerifiedName" TEXT;
-- AlterTable
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "passenger"."FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
-- AddForeignKey
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,26 +0,0 @@
/*
Warnings:
- You are about to drop the column `maskedHint` on the `PaymentMethod` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `PaymentMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[type]` on the table `PaymentMethod` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `PaymentMethod` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- DropIndex
DROP INDEX "PaymentMethod_userId_isDefault_idx";
-- AlterTable
ALTER TABLE "PaymentMethod" DROP COLUMN "maskedHint",
DROP COLUMN "userId",
ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'ETB',
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");

View File

@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';

View File

@@ -1,2 +0,0 @@
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -23,7 +23,10 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET');
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI');
-- CreateEnum
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
@@ -55,12 +58,25 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER
-- CreateEnum
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
-- CreateTable
CREATE TABLE "CoachType" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'passenger',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CoachType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeatClass" (
"id" TEXT NOT NULL,
"coachTypeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"basePrice" INTEGER NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -86,6 +102,9 @@ CREATE TABLE "User" (
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"faydaVerified" BOOLEAN NOT NULL DEFAULT false,
"faydaVerifiedAt" TIMESTAMP(3),
"faydaSub" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
@@ -211,16 +230,11 @@ CREATE TABLE "TripLiveStatus" (
-- CreateTable
CREATE TABLE "Coach" (
"id" TEXT NOT NULL,
"coachNumber" TEXT NOT NULL,
"label" TEXT NOT NULL,
"seatClassId" TEXT NOT NULL,
"coachType" TEXT,
"mode" TEXT NOT NULL DEFAULT 'seat',
"seatArrangement" TEXT,
"bedArrangement" TEXT,
"amenities" JSONB,
"totalUnits" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"coachTypeId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"arrangement" TEXT NOT NULL DEFAULT '2+2',
"capacity" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -243,10 +257,9 @@ CREATE TABLE "CoachAssignment" (
CREATE TABLE "Seat" (
"id" TEXT NOT NULL,
"coachId" TEXT NOT NULL,
"seatNumber" TEXT NOT NULL,
"row" INTEGER NOT NULL,
"col" TEXT NOT NULL,
"label" TEXT NOT NULL,
"seatNumber" TEXT,
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
"heldUntil" TIMESTAMP(3),
@@ -254,7 +267,6 @@ CREATE TABLE "Seat" (
"isAisle" BOOLEAN NOT NULL DEFAULT false,
"bedPosition" TEXT,
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
"eligibility" TEXT,
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
);
@@ -278,6 +290,7 @@ CREATE TABLE "FareRule" (
"id" TEXT NOT NULL,
"tripId" TEXT,
"route" TEXT,
"nationality" TEXT,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
@@ -303,6 +316,8 @@ CREATE TABLE "Booking" (
"displayCurrency" "Currency",
"displayTotalMinor" INTEGER,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"contactEmail" TEXT,
"contactPhone" TEXT,
"userAgent" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"promoCode" TEXT,
@@ -327,6 +342,9 @@ CREATE TABLE "BookingSeat" (
"passportCountry" TEXT,
"verifaydaVerified" BOOLEAN NOT NULL DEFAULT false,
"verifaydaData" JSONB,
"faydaVerifiedAt" TIMESTAMP(3),
"faydaSub" TEXT,
"faydaVerifiedName" TEXT,
"seatLabelSnapshot" TEXT,
"fareMinor" INTEGER,
"displayCurrency" "Currency",
@@ -338,13 +356,16 @@ CREATE TABLE "BookingSeat" (
-- CreateTable
CREATE TABLE "PaymentMethod" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "PaymentMethodType" NOT NULL,
"displayName" TEXT NOT NULL,
"maskedHint" TEXT,
"region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
"currency" TEXT NOT NULL DEFAULT 'ETB',
"providerId" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
);
@@ -423,6 +444,16 @@ CREATE TABLE "Ticket" (
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL,
@@ -1016,8 +1047,51 @@ CREATE TABLE "VerifaydaVerification" (
CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"platform" TEXT NOT NULL DEFAULT 'WEB',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"authCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SeatClass_name_key" ON "SeatClass"("name");
CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId");
-- CreateIndex
CREATE UNIQUE INDEX "SeatClass_coachTypeId_name_key" ON "SeatClass"("coachTypeId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -1025,6 +1099,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "User"("faydaSub");
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
@@ -1053,7 +1130,10 @@ CREATE UNIQUE INDEX "TripStopTime_scheduleId_sequence_key" ON "TripStopTime"("sc
CREATE UNIQUE INDEX "TripLiveStatus_scheduleId_key" ON "TripLiveStatus"("scheduleId");
-- CreateIndex
CREATE UNIQUE INDEX "Coach_coachNumber_key" ON "Coach"("coachNumber");
CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number");
-- CreateIndex
CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
-- CreateIndex
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
@@ -1062,11 +1142,14 @@ CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId")
CREATE UNIQUE INDEX "CoachAssignment_scheduleId_positionNumber_key" ON "CoachAssignment"("scheduleId", "positionNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
CREATE INDEX "Seat_coachId_idx" ON "Seat"("coachId");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
-- CreateIndex
CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt");
@@ -1077,7 +1160,7 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
-- CreateIndex
CREATE INDEX "PaymentMethod_userId_isDefault_idx" ON "PaymentMethod"("userId", "isDefault");
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
@@ -1100,6 +1183,12 @@ CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "Payme
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
-- CreateIndex
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
@@ -1202,6 +1291,30 @@ CREATE INDEX "VerifaydaVerification_nationalId_idx" ON "VerifaydaVerification"("
-- CreateIndex
CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt");
-- AddForeignKey
ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1214,6 +1327,9 @@ ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey"
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1230,7 +1346,7 @@ ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_stationId_fkey" FOREIGN
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1265,6 +1381,12 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey"
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1363,3 +1485,6 @@ ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("sea
-- AddForeignKey
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,30 +0,0 @@
-- Add contact fields to Booking table
ALTER TABLE "passenger"."Booking"
ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- Create SavedPassengerProfile table
CREATE TABLE "passenger"."SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "passenger"."IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- Create indexes
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "passenger"."SavedPassengerProfile"("userId");
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "passenger"."SavedPassengerProfile"("deviceId");
-- Add comment
COMMENT ON TABLE "passenger"."SavedPassengerProfile" IS 'Stores passenger details for quick rebooking (by userId or deviceId)';

View File

@@ -70,17 +70,34 @@ enum Currency {
@@schema("passenger")
}
model SeatClass {
model CoachType {
id String @id @default(uuid())
name String @unique
description String?
basePrice Int
isActive Boolean @default(true)
code String
name String
type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coaches Coach[]
seatClasses SeatClass[]
@@schema("passenger")
}
model SeatClass {
id String @id @default(uuid())
coachTypeId String
name String
description String?
baseFareMinor Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
fareRules FareRule[]
routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
@@unique([coachTypeId, name])
@@index([coachTypeId])
@@schema("passenger")
}
@@ -385,21 +402,17 @@ model TripLiveStatus {
model Coach {
id String @id @default(uuid())
coachNumber String @unique
label String
seatClassId String
coachType String?
mode String @default("seat") // 'seat', 'bed', 'convertible'
seatArrangement String?
bedArrangement String?
amenities Json?
totalUnits Int @default(0)
isActive Boolean @default(true)
coachTypeId String
number String @unique
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
capacity Int @default(0) // Total seats/beds
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[]
assignments CoachAssignment[]
@@index([coachTypeId])
@@schema("passenger")
}
@@ -422,10 +435,9 @@ model CoachAssignment {
model Seat {
id String @id @default(uuid())
coachId String
seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach)
row Int
col String
label String
seatNumber String?
kind SeatKind @default(STANDARD)
status SeatStatus @default(AVAILABLE)
heldUntil DateTime?
@@ -433,12 +445,13 @@ model Seat {
isAisle Boolean @default(false)
bedPosition String? // 'lower', 'middle', 'upper'
premiumFeeMinor Int @default(0)
eligibility String?
coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[]
blocks SeatBlock[]
@@unique([coachId, row, col])
ticketSeats TicketSeat[]
@@unique([coachId, seatNumber])
@@unique([coachId, row, col])
@@index([coachId])
@@schema("passenger")
}
@@ -627,6 +640,20 @@ model Ticket {
validatorId String?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
@@schema("passenger")
}
model TicketSeat {
id String @id @default(uuid())
ticketId String
seatId String
seatIndex Int @default(0)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
seat Seat @relation(fields: [seatId], references: [id])
@@index([ticketId])
@@index([seatId])
@@schema("passenger")
}
@@ -963,6 +990,7 @@ model Route {
createdAt DateTime @default(now())
stops RouteStop[]
fareRules RouteFareRule[]
segmentFares SegmentFareRule[]
schedules TrainSchedule[]
@@schema("passenger")
@@ -1002,6 +1030,26 @@ model RouteFareRule {
@@schema("passenger")
}
model SegmentFareRule {
id String @id @default(uuid())
routeId String
originStopSequence Int
destinationStopSequence Int
seatClassId String
baseFareMinor Int
nationality String? // Optional: Ethiopian, Djiboutian, Other
currency String @default("ETB")
validFrom DateTime
validUntil DateTime?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
@@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality])
@@index([routeId, seatClassId])
@@schema("passenger")
}
model Agent {
id String @id @default(uuid())
userId String @unique

View File

@@ -1,231 +0,0 @@
import { PrismaClient, SeatKind } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting complete seed...\n');
// 1. STATIONS
console.log('📍 Seeding stations...');
const stationData = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 },
{ code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 },
{ code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 },
{ code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 },
{ code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 },
{ code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 },
];
const stations = [];
for (const s of stationData) {
stations.push(await prisma.station.upsert({ where: { code: s.code }, update: {}, create: s }));
}
console.log(`${stations.length} stations\n`);
// 2. SEAT CLASSES
console.log('💺 Seeding seat classes...');
const scEconomy = await prisma.seatClass.upsert({
where: { name: 'Economy Regular' },
update: {},
create: { name: 'Economy Regular', description: 'Standard economy', basePrice: 25000, isActive: true },
});
const scBed = await prisma.seatClass.upsert({
where: { name: 'Economy Bed' },
update: {},
create: { name: 'Economy Bed', description: 'Economy bed', basePrice: 35000, isActive: true },
});
console.log(`✅ 2 seat classes\n`);
// 3. ROUTES
console.log('🛤️ Seeding routes...');
const route1 = await prisma.route.upsert({
where: { code: 'SBT-NGD' },
update: {},
create: { code: 'SBT-NGD', name: 'Sebeta-Nagad Express', effectiveFrom: new Date('2026-01-01'), active: true },
});
await prisma.routeStop.createMany({
data: [
{ routeId: route1.id, stationId: stations[0].id, sequence: 1, distanceKm: 0 },
{ routeId: route1.id, stationId: stations[1].id, sequence: 2, distanceKm: 15 },
{ routeId: route1.id, stationId: stations[2].id, sequence: 3, distanceKm: 28 },
{ routeId: route1.id, stationId: stations[3].id, sequence: 4, distanceKm: 45 },
{ routeId: route1.id, stationId: stations[4].id, sequence: 5, distanceKm: 73 },
{ routeId: route1.id, stationId: stations[5].id, sequence: 6, distanceKm: 99 },
{ routeId: route1.id, stationId: stations[6].id, sequence: 7, distanceKm: 378 },
{ routeId: route1.id, stationId: stations[7].id, sequence: 8, distanceKm: 756 },
],
skipDuplicates: true,
});
await prisma.routeFareRule.createMany({
data: [
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'ADULT', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'CHILD', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
],
skipDuplicates: true,
});
console.log(`✅ 1 route with stops and fares\n`);
// 4. TRAINS
console.log('🚂 Seeding trains...');
const train = await prisma.train.upsert({
where: { number: '301' },
update: {},
create: { number: '301', name: 'Express 301', description: 'Main Express' },
});
console.log(`✅ 1 train\n`);
// 5. COACHES & SEATS
console.log('🚃 Seeding coaches...');
const coach1 = await prisma.coach.upsert({
where: { coachNumber: 'C-A1' },
update: {},
create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 20 },
});
const existingSeats = await prisma.seat.count({ where: { coachId: coach1.id } });
if (existingSeats === 0) {
const seats = [];
for (let row = 1; row <= 5; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
seats.push({
coachId: coach1.id,
row,
col,
label: `${row}${col}`,
seatNumber: `A${row}${col}`,
kind: 'STANDARD' as SeatKind,
});
}
}
await prisma.seat.createMany({ data: seats });
}
console.log(`✅ 1 coach with 20 seats\n`);
// 6. SCHEDULE
console.log('📅 Seeding schedule...');
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
if (existingSchedules.length > 0) {
const scheduleIds = existingSchedules.map(s => s.id);
const bookingIds = (
await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } })
).map(b => b.id);
// Delete booking children in FK-safe order before deleting the bookings themselves
await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } });
await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.trainSchedule.deleteMany({ where: { trainId: train.id } });
}
const schedule = await prisma.trainSchedule.create({
data: {
trainId: train.id,
routeId: route1.id,
originStationId: stations[0].id,
destinationStationId: stations[7].id,
departureAt: new Date('2026-06-15T06:00:00Z'),
arrivalAt: new Date('2026-06-15T22:00:00Z'),
durationMinutes: 960,
stopsCount: 8,
},
});
await prisma.coachAssignment.create({
data: { scheduleId: schedule.id, coachId: coach1.id, positionNumber: 1 },
});
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule.id, stationId: stations[0].id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[1].id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[2].id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T08:00:00Z'), plannedDepartureAt: new Date('2026-06-15T08:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[3].id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[4].id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T10:00:00Z'), plannedDepartureAt: new Date('2026-06-15T10:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[5].id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T11:00:00Z'), plannedDepartureAt: new Date('2026-06-15T11:15:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[6].id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[7].id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' },
],
});
console.log(`✅ 1 schedule with stops\n`);
// 7. USERS
console.log('👥 Seeding users...');
const adminHash = await bcrypt.hash('admin123', 10);
const userHash = await bcrypt.hash('password123', 10);
await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: {},
create: { fullName: 'Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' },
});
const user = await prisma.user.upsert({
where: { email: 'abebe@email.com' },
update: {},
create: { fullName: 'Abebe Kebede', email: 'abebe@email.com', phone: '+251912345678', passwordHash: userHash, nationality: 'Ethiopian' },
});
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: user.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 1000, tier: 'BRONZE' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000 } });
}
console.log(`✅ 2 users\n`);
// 8. SUPPORTING DATA
console.log('📦 Seeding supporting data...');
await prisma.paymentMethod.upsert({
where: { type: 'TELEBIRR' },
update: {},
create: { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', enabled: true, sortOrder: 1 },
});
await prisma.currencyExchangeRate.deleteMany({});
await prisma.currencyExchangeRate.createMany({
data: [
{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() },
],
});
console.log(`✅ Payment methods and currencies\n`);
console.log('✅ SEED COMPLETE!\n');
console.log('📋 Summary:');
console.log(' - 8 Stations');
console.log(' - 2 Seat Classes');
console.log(' - 1 Route with 8 stops');
console.log(' - 1 Train with 1 schedule');
console.log(' - 1 Coach with 20 seats');
console.log(' - 2 Users (Admin + Passenger)');
console.log('\n🔑 Credentials:');
console.log(' Admin: admin@edr-platform.com / admin123');
console.log(' User: abebe@email.com / password123');
}
main()
.catch((e) => {
console.error('❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
// import { EventEmitterModule } from '@nestjs/event-emitter';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
@@ -14,6 +14,7 @@ import ebirrConfig from './config/ebirr.config';
import cardConfig from './config/card.config';
import waafiConfig from './config/waafi.config';
import faydaConfig from './config/fayda.config';
import rabbitmqConfig from './config/rabbitmq.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
@@ -52,6 +53,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
cardConfig,
waafiConfig,
faydaConfig,
rabbitmqConfig,
],
}),
ScheduleModule.forRoot(),

View File

@@ -12,6 +12,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
// Global filter — also reached by non-HTTP (e.g. RabbitMQ) handlers. switchToHttp() would
// yield no response object there, so re-throw and let the transport (golevelup) handle it
// (nack/dead-letter) instead of crashing on response.status().
if (host.getType() !== 'http') {
throw exception;
}
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();

View File

@@ -0,0 +1,54 @@
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 enforces on its own internal surface. A forged mark-paid must not be able
* to confirm a booking without a real payment.
* TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism.
*/
@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

@@ -4,7 +4,14 @@ import { map } from 'rxjs/operators';
@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(_ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
intercept(ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
// Only wrap HTTP responses. This interceptor is global, so it also runs for RabbitMQ
// message handlers (golevelup uses Nest's context system) — there, wrapping the return
// value would corrupt the handler's contract (e.g. a returned Nack would be swallowed,
// dropping a message instead of dead-lettering it). Let non-HTTP returns pass through.
if (ctx.getType() !== 'http') {
return next.handle();
}
return next.handle().pipe(
map((data) => ({ success: true, data, timestamp: new Date().toISOString() })),
);

View File

@@ -0,0 +1,12 @@
import { registerAs } from '@nestjs/config';
/**
* RabbitMQ connection for the payment-event consumer (payment microservice -> passenger).
* Independent of the IAM/notification module's RABBITMQ_URL so the two broker connections
* never interfere. 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

@@ -1,10 +1,27 @@
import { registerAs } from '@nestjs/config';
export default registerAs('waafi', () => ({
baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net',
// `/asm` is appended in the provider; use sandbox by default, switch to
// https://api.waafipay.net in production.
baseUrl: process.env.WAAFI_BASE_URL ?? 'https://sandbox.waafipay.net',
// HPP credentials (Hosted Payment Page family).
merchantUid: process.env.WAAFI_MERCHANT_UID ?? '',
apiUserId: process.env.WAAFI_API_USER_ID ?? '',
apiKey: process.env.WAAFI_API_KEY ?? '',
storeId: process.env.WAAFI_STORE_ID ?? '',
hppKey: process.env.WAAFI_HPP_KEY ?? '',
// HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks.
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? '',
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? 'MWALLET_ACCOUNT',
// Waafi has no ETB; when set this overrides the booking currency (USD/DJF/SLSH).
currency: process.env.WAAFI_CURRENCY ?? 'DJF',
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? '',
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? '',
// Callback data format: 1 = POST, 2 = GET, 4 = Result Token.
respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? '1'),
// Registered webhook URL (reference only; registration is performed out-of-band).
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '',
returnUrl: process.env.WAAFI_RETURN_URL ?? '',
// DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that
// does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod.
insecureTls: process.env.WAAFI_INSECURE_TLS === 'true',
}));

View File

@@ -8,7 +8,9 @@ import { ResponseTransformInterceptor } from "./common/interceptors/response-tra
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true });
app.enableCors({
origin: [
@@ -236,6 +238,7 @@ Payment providers send notifications to:
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
.addTag("Config", "System configuration and settings")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();

View File

@@ -1,8 +1,11 @@
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator';
import { UserRole } from '@prisma/client';
@ApiTags('Auth')
@Controller('auth')
@@ -137,6 +140,9 @@ export class AuthController {
- **Loyalty Account**: Tier, points balance, lifetime points
- **Wallet Account**: Balance (minor units), currency
#### Devices
- List of registered devices with platform, name, push token, and last seen time
#### User Preferences
- Language, notification settings, etc.
@@ -154,6 +160,8 @@ export class AuthController {
5. **Wallet Balance**: Display available balance
6. **Device Management**: Get list of user's registered devices
---
### Authentication
@@ -196,7 +204,25 @@ export class AuthController {
emailNotifications: true,
smsNotifications: true,
language: 'am'
}
},
devices: [
{
id: 'device-uuid-1',
platform: 'WEB',
name: 'Chrome on Windows',
pushToken: 'token-abc123',
trusted: true,
lastSeenAt: '2024-01-20T14:22:00.000Z'
},
{
id: 'device-uuid-2',
platform: 'IOS',
name: 'iPhone 14',
pushToken: 'token-xyz789',
trusted: false,
lastSeenAt: '2024-01-19T10:15:00.000Z'
}
]
}
}
})
@@ -217,4 +243,61 @@ export class AuthController {
}
return this.service.getProfile(req.user.userId);
}
@Get('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' })
getUsers(
@Query('search') search?: string,
@Query('role') role?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getUsers({
search,
role,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Post('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' })
createUser(@Body() dto: any) {
return this.service.createUser(dto);
}
@Patch('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' })
updateUser(@Param('id') id: string, @Body() dto: any) {
return this.service.updateUser(id, dto);
}
@Delete('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete backoffice user (admin only)' })
deleteUser(@Param('id') id: string) {
return this.service.deleteUser(id);
}
@Post('users/:id/reset-password')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' })
resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) {
return this.service.resetUserPassword(id, dto.tempPassword);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
@@ -58,7 +58,7 @@ export class AuthService {
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null }
data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
@@ -131,6 +131,174 @@ export class AuthService {
return { reset: true };
}
async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
const { search, role, status, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {
role: { not: 'PASSENGER' }, // Exclude passenger accounts
};
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ fullName: { contains: search, mode: 'insensitive' } },
];
}
if (role) {
where.role = role;
}
// For status filtering, we check if user is active (no lock/block) or inactive
if (status === 'ACTIVE') {
where.AND = [
{ blockedUntil: { lte: new Date() } },
{ lockedUntil: { lte: new Date() } }
];
} else if (status === 'INACTIVE') {
where.OR = [
{ blockedUntil: { gt: new Date() } },
{ lockedUntil: { gt: new Date() } }
];
}
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
blockedUntil: true,
lockedUntil: true,
},
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
return {
items: items.map(user => ({
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
lastLogin: user.lastLoginAt,
status: (!user.blockedUntil || user.blockedUntil <= new Date()) &&
(!user.lockedUntil || user.lockedUntil <= new Date())
? 'ACTIVE'
: 'INACTIVE',
})),
total,
page,
pageSize,
};
}
async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) {
const exists = await this.prisma.user.findFirst({
where: { OR: [{ email: dto.email }] },
});
if (exists) throw new ConflictException('Email already registered');
const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10);
const user = await this.prisma.user.create({
data: {
email: dto.email,
fullName: dto.fullName,
role: dto.role as any,
phone: dto.email, // Use email as phone temporarily for unique constraint
passwordHash,
blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined,
},
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
},
});
await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role });
return user;
}
async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
const updateData: any = {};
if (dto.fullName) updateData.fullName = dto.fullName;
if (dto.role) updateData.role = dto.role;
if (dto.status === 'ACTIVE') {
updateData.blockedUntil = null;
updateData.lockedUntil = null;
} else if (dto.status === 'INACTIVE') {
updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
}
const updated = await this.prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
},
});
await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData });
return updated;
}
async deleteUser(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
// Don't actually delete, just deactivate
await this.prisma.user.update({
where: { id },
data: { blockedUntil: new Date(), lockedUntil: new Date() },
});
await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null);
return { deleted: true };
}
async resetUserPassword(id: string, tempPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
const passwordHash = await bcrypt.hash(tempPassword, 10);
await this.prisma.user.update({
where: { id },
data: {
passwordHash,
failedLoginAttempts: 0,
lockedUntil: null,
},
});
await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' });
return { reset: true, tempPassword };
}
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
// Get the full user data to include fullName
const user = await this.prisma.user.findUnique({
@@ -180,6 +348,7 @@ export class AuthService {
},
},
preferences: true,
devices: true,
},
});
@@ -213,6 +382,14 @@ export class AuthService {
} : null,
} : null,
preferences: user.preferences,
devices: user.devices.map(device => ({
id: device.id,
platform: device.platform,
name: device.name,
pushToken: device.pushToken,
trusted: device.trusted,
lastSeenAt: device.lastSeenAt,
})),
};
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
@@ -15,7 +15,7 @@ export class BookingsController {
private guestService: GuestBookingService,
) {}
@Get('my/bookings')
@Get('my')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
@@ -43,6 +43,34 @@ export class BookingsController {
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get('by-device')
@ApiOperation({
summary: 'Get bookings by device ID',
description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.'
})
@ApiQuery({ name: 'deviceId', required: true, description: 'Device identifier' })
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
@ApiResponse({ status: 200, description: 'List of guest bookings and saved passengers for device' })
@ApiResponse({ status: 400, description: 'Device ID is required' })
getByDevice(
@Query('deviceId') deviceId?: string,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!deviceId) throw new BadRequestException('Device ID is required');
return this.service.findByDeviceId(deviceId, {
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get()
@ApiOperation({

View File

@@ -102,6 +102,83 @@ export class BookingsService {
};
}
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Find user with this device ID
const device = await this.prisma.device.findUnique({
where: { id: deviceId },
include: { user: { include: { passenger: true } } },
}).catch(() => null);
const searchConditions = search ? [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
] : [];
const where: any = {
OR: [
{ userAgent: deviceId },
...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []),
],
};
if (search) {
where.AND = [{ OR: searchConditions }];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
@@ -218,7 +295,6 @@ export class BookingsService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Use first passenger's nationality for fare lookup (or allow per-passenger pricing)
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const adultFareMinor = baseFareMinor * adultCount;
@@ -316,7 +392,7 @@ export class BookingsService {
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } },
seats: { include: { seat: { include: { coach: true } } } },
paymentIntent: true, ticket: true,
},
});
@@ -332,7 +408,7 @@ export class BookingsService {
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
},
passengers: booking.seats.map((bs) => ({
passengers: booking.seats?.map((bs: any) => ({
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
})),

View File

@@ -250,6 +250,7 @@ export class GuestBookingService {
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
userAgent: dto.deviceId,
// contactEmail: firstPassenger.email, // Temporarily disabled until migration
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration
seats: {

View File

@@ -35,7 +35,7 @@ export class DashboardService {
upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
departureAt: upcomingBooking.schedule.departureAt,
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null,

View File

@@ -44,7 +44,7 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
const ratePerKmMinor = seatClass.basePrice;
const ratePerKmMinor = seatClass.baseFareMinor;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
const adultCount = dto.adultCount ?? 1;
@@ -129,7 +129,7 @@ export class FareEngineService {
) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { basePrice: 'asc' },
orderBy: { baseFareMinor: 'asc' },
});
const results = await Promise.all(
@@ -172,7 +172,7 @@ export class FareEngineService {
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { basePrice: 'asc' },
orderBy: { baseFareMinor: 'asc' },
});
const results = await Promise.all(
@@ -198,23 +198,25 @@ export class FareEngineService {
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
include: { seatClass: true },
orderBy: { seatClass: { basePrice: 'asc' } },
orderBy: [{ seatClass: { baseFareMinor: 'asc' } }],
});
if (fareRules.length > 0) {
const billingCurrency = resolveCurrencyFromNationality(nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => ({
seatClassId: rule.seatClassId,
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
}));
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};
});
}
throw new BadRequestException(

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet')
@@ -11,16 +11,128 @@ import { JwtGuard } from '../../common/jwt.guard';
export class FleetController {
constructor(private service: FleetService) {}
// Coach Type Endpoints
@Get('coach-types')
@ApiOperation({ summary: 'List all coach types' })
@ApiResponse({ status: 200, description: 'Array of coach types' })
getCoachTypes() {
return this.service.getCoachTypes();
}
@Post('coach-types')
@ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' })
createCoachType(@Body() dto: CreateCoachTypeDto) {
return this.service.createCoachType(dto);
}
@Patch('coach-types/:id')
@ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto })
@ApiResponse({ status: 200, description: 'Coach type updated' })
@ApiResponse({ status: 404, description: 'Coach type not found' })
updateCoachType(@Param('id') id: string, @Body() dto: UpdateCoachTypeDto) {
return this.service.updateCoachType(id, dto);
}
@Delete('coach-types/:id')
@ApiOperation({ summary: 'Delete a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiResponse({ status: 200, description: 'Coach type deleted' })
@ApiResponse({ status: 404, description: 'Coach type not found' })
deleteCoachType(@Param('id') id: string) {
return this.service.deleteCoachType(id);
}
// Class Endpoints
@Get('classes')
@ApiOperation({ summary: 'List all classes' })
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
@ApiResponse({ status: 200, description: 'Array of classes' })
getClasses(@Query('coachTypeId') coachTypeId?: string) {
return this.service.getClasses(coachTypeId);
}
@Post('classes')
@ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
createClass(@Body() dto: CreateClassDto) {
return this.service.createClass(dto);
}
@Patch('classes/:id')
@ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@ApiResponse({ status: 200, description: 'Class updated' })
@ApiResponse({ status: 404, description: 'Class not found' })
updateClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
return this.service.updateClass(id, dto);
}
@Delete('classes/:id')
@ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteClass(@Param('id') id: string) {
return this.service.deleteClass(id);
}
// Seat Class Endpoints (DEPRECATED - use Classes endpoints instead)
@Get('seat-classes')
@ApiOperation({ summary: 'List all classes (DEPRECATED - use /fleet/classes)' })
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
@ApiResponse({ status: 200, description: 'Array of classes' })
getSeatClasses(@Query('coachTypeId') coachTypeId?: string) {
return this.service.getClasses(coachTypeId);
}
@Post('seat-classes')
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
createSeatClass(@Body() dto: CreateClassDto) {
return this.service.createClass(dto);
}
@Patch('seat-classes/:id')
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@ApiResponse({ status: 200, description: 'Class updated' })
@ApiResponse({ status: 404, description: 'Class not found' })
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
return this.service.updateClass(id, dto);
}
@Delete('seat-classes/:id')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteSeatClass(@Param('id') id: string) {
return this.service.deleteClass(id);
}
// Train Endpoints
@Get('trains')
@ApiOperation({ summary: 'List all trains with their recent schedules' })
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' })
getTrains() { return this.service.getTrains(); }
@ApiResponse({ status: 200, description: 'Array of trains' })
getTrains() {
return this.service.getTrains();
}
@Post('trains')
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
createTrain(@Body() dto: CreateTrainDto) {
return this.service.createTrain(dto);
}
@Patch('trains/:id')
@ApiOperation({ summary: 'Update a train service' })
@@ -28,96 +140,95 @@ export class FleetController {
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 200, description: 'Train updated' })
@ApiResponse({ status: 404, description: 'Train not found' })
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); }
@Get('coaches')
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
@ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' })
@ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' })
listCoaches(
@Query('isActive') isActive?: string,
@Query('mode') mode?: string,
@Query('seatClassId') seatClassId?: string,
@Query('scheduleId') scheduleId?: string,
) {
const dto: ListCoachesDto = {
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
mode,
seatClassId,
scheduleId,
};
return this.service.listCoaches(dto);
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) {
return this.service.updateTrain(id, dto);
}
@Get('coaches/:id')
@ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({
status: 200,
description: `Coach detail including:
- seatClass: seat class info
- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor
- seatStatusSummary: total/available/held/booked/blocked counts
- assignments: up to 5 most recent schedule assignments with origin/destination`,
})
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) { return this.service.getCoach(id); }
@Post('coaches')
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' })
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
@Patch('coaches/:id')
@ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
@Delete('trains/:id')
@ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train deleted' })
@ApiResponse({ status: 404, description: 'Train not found' })
deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); }
deleteTrain(@Param('id') id: string) {
return this.service.deleteTrain(id);
}
// Coach Endpoints
@Get('coaches')
@ApiOperation({ summary: 'List coaches with seat status summary' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
@ApiResponse({ status: 200, description: 'Array of coaches' })
listCoaches(
@Query('status') status?: string,
@Query('scheduleId') scheduleId?: string,
) {
const dto: ListCoachesDto = {
status,
scheduleId,
};
return this.service.listCoaches(dto);
}
@Get('coaches/:id')
@ApiOperation({ summary: 'Get single coach with seat layout' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) {
return this.service.getCoach(id);
}
@Post('coaches')
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created' })
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) {
return this.service.createCoach(dto);
}
@Patch('coaches/:id')
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
return this.service.updateCoach(id, dto);
}
@Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach deleted' })
@ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); }
deleteCoach(@Param('id') id: string) {
return this.service.deleteCoach(id);
}
@Post('assignments')
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
@ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
@ApiResponse({ status: 201, description: 'Coach assigned' })
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); }
assignCoach(@Body() dto: AssignCoachDto) {
return this.service.assignCoach(dto);
}
@Delete('assignments/:id')
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' })
@ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'Assignment UUID' })
@ApiResponse({ status: 200, description: 'Assignment removed' })
@ApiResponse({ status: 404, description: 'Assignment not found' })
removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); }
@Post('seats/batch')
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
@ApiBody({ type: CreateSeatBatchDto })
@ApiResponse({ status: 201, description: 'Returns count of seats created' })
@ApiResponse({ status: 404, description: 'Coach not found' })
createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
removeAssignment(@Param('id') id: string) {
return this.service.removeAssignment(id);
}
@Get('analytics')
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' })
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' })
getAnalytics() { return this.service.getAnalytics(); }
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
getAnalytics() {
return this.service.getAnalytics();
}
}

View File

@@ -10,41 +10,57 @@ export class CreateTrainDto {
}
export class CreateCoachDto {
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string;
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string;
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string;
@ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number;
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
export class AssignCoachDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number;
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean;
}
export class CreateSeatBatchDto {
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string;
@ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[];
@ApiProperty({ example: 1, description: 'Position in the train consist' }) @IsInt() positionNumber: number;
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational' }) @IsOptional() @IsBoolean() isOperational?: boolean;
}
export class ListCoachesDto {
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
@IsOptional() @IsBoolean() isActive?: boolean;
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Filter by status: ACTIVE, INACTIVE' })
@IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' })
@IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' })
@IsOptional() @IsString() seatClassId?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter coaches assigned to this schedule' })
@IsOptional() @IsString() scheduleId?: string;
}
// Legacy DTO types for backward compatibility
export class CreateCoachTypeDto {
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
@IsOptional() @IsString() type?: string;
}
export class UpdateCoachTypeDto {
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
}
export class CreateClassDto {
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
}
export class UpdateClassDto {
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
import { SeatKind } from '@prisma/client';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
@@ -8,22 +8,20 @@ function parseArrangement(arrangement: string): number[] {
return arrangement.split('+').map((n) => parseInt(n, 10));
}
// Derives column labels from a seat-mode arrangement string.
// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle)
// '1+2+1' → ['A','B','C','D']
// Derives column labels from arrangement: '2+2' → ['A','B','C','D']
function seatCols(arrangement: string): string[] {
const groups = parseArrangement(arrangement);
const total = groups.reduce((s, n) => s + n, 0);
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); // A, B, C …
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i));
}
// Returns true if the column index is a window seat given the arrangement groups.
// Returns true if column is a window seat
function isWindowCol(colIndex: number, groups: number[]): boolean {
const total = groups.reduce((s, n) => s + n, 0);
return colIndex === 0 || colIndex === total - 1;
}
// Returns true if the column index is an aisle seat.
// Returns true if column is an aisle seat
function isAisleCol(colIndex: number, groups: number[]): boolean {
let cursor = 0;
for (const g of groups) {
@@ -35,82 +33,188 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
return false;
}
// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper
const BED_POSITIONS: Record<number, string[]> = {
2: ['lower', 'upper'],
3: ['lower', 'middle', 'upper'],
};
type SeatRow = {
coachId: string;
row: number;
col: string;
label: string;
seatNumber: string;
kind: SeatKind;
isWindow: boolean;
isAisle: boolean;
bedPosition?: string;
};
function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
const cols = seatCols(arrangement);
const groups = parseArrangement(arrangement);
const seats: SeatRow[] = [];
let row = 1;
while (seats.length < totalUnits) {
for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) {
let seatNumber = 1;
let seatIndex = 0;
const isBedCoach = seatClass?.toLowerCase().includes('bed');
const totalCols = cols.length;
while (seatIndex < capacity) {
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
const col = cols[ci];
let bedPosition = null;
// Set bedPosition for bed coaches based on seat number cycling
if (isBedCoach) {
if (totalCols === 3) {
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
const posMod = ((seatNumber - 1) % 3);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'middle';
else if (posMod === 2) bedPosition = 'upper';
} else if (totalCols === 2) {
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
const posMod = ((seatNumber - 1) % 2);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'upper';
}
}
seats.push({
coachId, row, col,
label: `${row}${col}`,
seatNumber: `${coachLabel}${row}${col}`,
coachId,
row,
col,
seatNumber: `${seatNumber}`,
kind: SeatKind.STANDARD,
isWindow: isWindowCol(ci, groups),
isAisle: isAisleCol(ci, groups),
bedPosition,
});
seatNumber++;
seatIndex++;
}
row++;
}
return seats;
}
function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
// arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side
// Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper)
const groups = parseArrangement(arrangement);
const tiersPerSide = groups[0]; // e.g. 2 → lower+upper
const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper'];
const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle
const seats: SeatRow[] = [];
let compartment = 1;
while (seats.length < totalUnits) {
for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) {
const col = tierCols[ti];
seats.push({
coachId, row: compartment, col,
label: `${compartment}${col}`,
seatNumber: `${coachLabel}${compartment}${col}`,
kind: SeatKind.STANDARD,
isWindow: false,
isAisle: false,
bedPosition: positions[ti],
});
}
compartment++;
}
return seats;
}
type SeatRow = {
coachId: string;
row: number;
col: string;
seatNumber: string;
kind: SeatKind;
bedPosition?: string | null;
};
@Injectable()
export class FleetService {
constructor(private prisma: PrismaService) {}
async createCoachType(dto: CreateCoachTypeDto) {
return this.prisma.coachType.create({
data: {
code: dto.code,
name: dto.name,
type: dto.type || 'passenger',
},
include: {
seatClasses: true,
coaches: true,
},
});
}
async getCoachTypes() {
return this.prisma.coachType.findMany({
include: {
seatClasses: true,
coaches: true,
},
orderBy: { createdAt: 'desc' },
});
}
async updateCoachType(id: string, dto: UpdateCoachTypeDto) {
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
if (!coachType) throw new NotFoundException('Coach type not found');
const data: any = {};
if (dto.code !== undefined) data.code = dto.code;
if (dto.name !== undefined) data.name = dto.name;
if (dto.type !== undefined) data.type = dto.type;
return this.prisma.coachType.update({
where: { id },
data,
include: {
seatClasses: true,
coaches: true,
},
});
}
async deleteCoachType(id: string) {
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
if (!coachType) throw new NotFoundException('Coach type not found');
return this.prisma.coachType.delete({ where: { id } });
}
async createClass(dto: CreateClassDto) {
return this.prisma.seatClass.create({
data: {
coachTypeId: dto.coachTypeId,
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
},
});
}
async getClasses(coachTypeId?: string) {
const where = coachTypeId ? { coachTypeId } : {};
return this.prisma.seatClass.findMany({
where,
include: { coachType: true },
orderBy: { createdAt: 'desc' },
});
}
async updateClass(id: string, dto: UpdateClassDto) {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
const updateData: any = {
coachTypeId: dto.coachTypeId,
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
};
if (dto.isActive !== undefined) {
updateData.isActive = dto.isActive;
}
return this.prisma.seatClass.update({
where: { id },
data: updateData,
include: { coachType: true },
});
}
async deleteClass(id: string) {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
return this.prisma.seatClass.delete({ where: { id } });
}
createSeatClass(dto: CreateClassDto) {
return this.createClass(dto);
}
getSeatClasses(coachTypeId?: string) {
return this.getClasses(coachTypeId);
}
async updateSeatClass(id: string, dto: UpdateClassDto) {
return this.updateClass(id, dto);
}
async deleteSeatClass(id: string) {
return this.deleteClass(id);
}
getTrains() {
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
}
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); }
createTrain(dto: CreateTrainDto) {
return this.prisma.train.create({ data: dto });
}
async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } });
@@ -118,120 +222,112 @@ export class FleetService {
return this.prisma.train.update({ where: { id }, data: dto });
}
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
seatClass: true,
seats: {
orderBy: [{ row: 'asc' }, { col: 'asc' }],
},
assignments: {
include: { schedule: { include: { originStation: true, destinationStation: true } } },
orderBy: { schedule: { departureAt: 'desc' } },
take: 5,
},
_count: { select: { seats: true, assignments: true } },
},
});
if (!coach) throw new NotFoundException('Coach not found');
// Group seats by row to reflect the physical arrangement layout
const rowMap = new Map<number, typeof coach.seats>();
for (const seat of coach.seats) {
if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
rowMap.get(seat.row)!.push(seat);
}
const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats }));
const seatStatusSummary = {
total: coach.seats.length,
available: coach.seats.filter(s => s.status === 'AVAILABLE').length,
held: coach.seats.filter(s => s.status === 'HELD').length,
booked: coach.seats.filter(s => s.status === 'BOOKED').length,
blocked: coach.seats.filter(s => s.status === 'BLOCKED').length,
};
const { seats, ...coachData } = coach;
return { ...coachData, seatsByRow, seatStatusSummary };
}
async listCoaches(dto: ListCoachesDto) {
const where: any = {};
if (dto.isActive !== undefined) where.isActive = dto.isActive;
if (dto.mode) where.mode = dto.mode;
if (dto.seatClassId) where.seatClassId = dto.seatClassId;
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
const coaches = await this.prisma.coach.findMany({
where,
include: {
seatClass: true,
seats: { select: { status: true } },
_count: { select: { seats: true, assignments: true } },
},
orderBy: [{ isActive: 'desc' }, { label: 'asc' }],
});
return coaches.map(({ seats, ...coach }) => ({
...coach,
seatStatusSummary: {
total: seats.length,
available: seats.filter(s => s.status === 'AVAILABLE').length,
held: seats.filter(s => s.status === 'HELD').length,
booked: seats.filter(s => s.status === 'BOOKED').length,
blocked: seats.filter(s => s.status === 'BLOCKED').length,
},
}));
}
async createCoach(dto: CreateCoachDto) {
const mode = dto.mode ?? 'seat';
const totalUnits = dto.totalUnits ?? 0;
const isBed = mode === 'bed';
const arrangement = isBed
? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2')
: (dto.seatArrangement ?? '2+2');
if (totalUnits > 0) {
const groups = parseArrangement(arrangement);
if (groups.some(isNaN)) {
throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`);
}
}
const coach = await this.prisma.coach.create({ data: dto });
if (totalUnits > 0) {
const seats = isBed
? buildBedSeats(coach.id, coach.label, arrangement, totalUnits)
: buildSeatSeats(coach.id, coach.label, arrangement, totalUnits);
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
}
return this.prisma.coach.findUnique({
where: { id: coach.id },
include: { seatClass: true, _count: { select: { seats: true } } },
});
}
async updateCoach(id: string, dto: UpdateCoachDto) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.update({ where: { id }, data: dto });
}
async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.delete({ where: { id } });
}
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
coachType: true,
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
assignments: {
include: { schedule: { include: { originStation: true, destinationStation: true } } },
orderBy: { schedule: { departureAt: 'desc' } },
take: 5,
},
},
});
if (!coach) throw new NotFoundException('Coach not found');
return coach;
}
async listCoaches(dto: ListCoachesDto) {
const where: any = {};
if (dto.status) where.status = dto.status;
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
return this.prisma.coach.findMany({
where,
include: { coachType: true },
orderBy: { number: 'asc' },
});
}
async createCoach(dto: CreateCoachDto) {
const groups = parseArrangement(dto.arrangement);
if (groups.some(isNaN)) {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
}
const coach = await this.prisma.coach.create({
data: {
coachTypeId: dto.coachTypeId,
number: dto.number,
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status || 'ACTIVE',
},
include: { coachType: true },
});
if (dto.capacity > 0) {
const seatClass = coach.coachType?.name || '';
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
await this.prisma.seat.createMany({ data: seats });
}
return coach;
}
async updateCoach(id: string, dto: UpdateCoachDto) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.update({
where: { id },
data: {
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status,
},
include: { coachType: true },
});
}
async deleteCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
// Get all seat IDs for this coach
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
const seatIds = seats.map(s => s.id);
// Delete in order of foreign key dependencies
if (seatIds.length > 0) {
// 1. Delete seat blocks (references seats)
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
// 2. Delete ticket seats (references seats)
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 3. Delete booking seats (references seats)
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 4. Delete journey segments with these seats
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
}
// 5. Delete all associated seats
await this.prisma.seat.deleteMany({ where: { coachId: id } });
// 6. Delete coach assignments
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
// 7. Finally delete the coach
return this.prisma.coach.delete({ where: { id } });
}
@@ -251,19 +347,6 @@ export class FleetService {
return this.prisma.coachAssignment.delete({ where: { id } });
}
async createSeatBatch(dto: CreateSeatBatchDto) {
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
if (!coach) throw new NotFoundException('Coach not found');
const seats = [];
for (let row = 1; row <= dto.rows; row++) {
for (const col of dto.cols) {
seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` });
}
}
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
}
async getAnalytics() {
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
this.prisma.train.count(),
@@ -271,6 +354,12 @@ export class FleetService {
this.prisma.seat.count(),
this.prisma.seat.count({ where: { status: 'BOOKED' } }),
]);
return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
return {
totalTrains,
totalSchedules,
totalSeats,
bookedSeats,
occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0,
};
}
}

View File

@@ -92,22 +92,22 @@ export class PassengersService {
}
async getProfile(passengerId: string) {
const p = await this.prisma.passenger.findUnique({
const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId },
include: {
user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
},
});
if (!p) throw new NotFoundException('Passenger not found');
if (!passenger) throw new NotFoundException('Passenger not found');
return {
id: p.id,
fullName: p.user.fullName,
email: p.user.email,
phone: p.user.phone,
createdAt: p.createdAt,
bookings: p.bookings.map((b) => ({
id: passenger.id,
fullName: passenger.user.fullName,
email: passenger.user.email,
phone: passenger.user.phone,
createdAt: passenger.createdAt,
bookings: passenger.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
trip: {
number: b.schedule.train.number,
@@ -115,7 +115,7 @@ export class PassengersService {
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
departureAt: b.schedule.departureAt,
},
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })),
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
})),
};
}
@@ -207,7 +207,6 @@ export class PassengersService {
const isLoggedIn = !!dto.userId;
let verifiedData: any = null;
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
if (isEthiopian && dto.verifyWithFayda !== false) {
try {
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
@@ -215,12 +214,10 @@ export class PassengersService {
verifiedData = verification.passengerData;
}
} catch (error) {
// If verification fails, continue with manual data
console.warn('Fayda verification failed, using manual data:', error);
}
}
// Use verified data if available, otherwise use provided data
const finalData = {
passengerName: verifiedData?.fullName || dto.passengerName,
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
@@ -230,7 +227,6 @@ export class PassengersService {
email: dto.email,
};
// If logged in, update user profile and link passenger
if (isLoggedIn) {
const user = await this.prisma.user.findUnique({
where: { id: dto.userId },
@@ -241,7 +237,6 @@ export class PassengersService {
throw new BadRequestException('User not found');
}
// Update user record if not already verified
if (!user.faydaVerified && verifiedData) {
await this.prisma.user.update({
where: { id: dto.userId },
@@ -267,7 +262,6 @@ export class PassengersService {
};
}
// Guest user - save to SavedPassengerProfile
const profile = await this.prisma.savedPassengerProfile.create({
data: {
deviceId: dto.deviceId,
@@ -318,4 +312,4 @@ export class PassengersService {
affectedModules: usage,
};
}
}
}

View File

@@ -0,0 +1,35 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentsService } from "./payments.service";
/**
* Consumer side of the payment microservice's outbox relay (docs/payment-service §7.3).
* Only the payment service may call this (shared service token). Idempotent by design:
* the relay delivers at-least-once, so duplicates must be harmless. Becomes a queue
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
*/
@ApiTags("Internal Payments")
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
export class InternalPaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Post("mark-paid")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
"Apply a payment.succeeded/payment.failed event from the payment service (idempotent)",
})
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
return this.paymentsService.handlePaymentEvent(event);
}
}

View File

@@ -0,0 +1,57 @@
import {
IsEnum,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsPositive,
IsString,
IsUUID,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
PaymentEventType,
PaymentReferenceType,
PaymentService,
ProviderMethod,
} from "@edr/types";
/**
* Wire shape of the `PaymentEvent` envelope (@edr/types) delivered by the payment
* microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent.
*/
export class PaymentEventDto {
@ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1;
@ApiProperty() @IsUUID() eventId!: string;
@ApiProperty({ enum: ["payment.succeeded", "payment.failed"] })
@IsIn(["payment.succeeded", "payment.failed"])
eventType!: PaymentEventType;
@ApiProperty() @IsISO8601() occurredAt!: string;
@ApiProperty({ enum: PaymentService })
@IsEnum(PaymentService)
service!: string;
@ApiProperty() @IsUUID() intentId!: string;
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: string;
@ApiProperty() @IsString() referenceId!: string;
@ApiProperty() @IsString() merchantOrderId!: string;
@ApiProperty({ enum: ProviderMethod })
@IsEnum(ProviderMethod)
provider!: string;
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
@ApiProperty() @IsString() currency!: string;
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
@ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string;
@ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string;
@ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string;
}
export class MarkPaidResponseDto {
@ApiProperty() processed!: boolean;
@ApiPropertyOptional() alreadyFinalized?: boolean;
@ApiPropertyOptional() reason?: string;
}

View File

@@ -0,0 +1,95 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
import {
InitiatePaymentRequest,
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
} from "@edr/types";
/**
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
* provider calls, intents, and webhooks live in the payment service.
*/
@Injectable()
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "http://localhost:3003"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
constructor(private readonly http: HttpService) {}
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
return this.call("POST", "/payments/initiate", request);
}
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
async getIntentByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntentSnapshot | null> {
const query = new URLSearchParams({
service: PaymentService.PASSENGER,
referenceType,
referenceId,
});
try {
return await this.call("GET", `/payments/intents?${query.toString()}`);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404)
return null;
throw err;
}
}
private async call<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
this.logger.log("=====================================================================");
this.logger.log(`URL ${url}`);
this.logger.log("=====================================================================");
try {
const response = await firstValueFrom(
this.http.request<T>({
method,
url,
data: body,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
}),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
// 4xx/5xx from the payment service: propagate 404 to callers that handle it;
// everything else is a gateway-level failure from the client's perspective.
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
this.logger.error(
`payment service ${method} ${path}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
const message =
err instanceof Error && err.message ? err.message : String(err);
this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`,
);
throw new BadGatewayException("Payment service unreachable");
}
}
}

View File

@@ -0,0 +1,47 @@
import { Injectable, Logger } from '@nestjs/common';
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import {
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
PAYMENT_QUEUES,
PaymentEvent,
PaymentService,
paymentServiceBindingPattern,
} from '@edr/types';
import { PaymentEventDto } from './internal-payments.dto';
import { PaymentsService } from './payments.service';
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
@Injectable()
export class PaymentEventsConsumer {
private readonly logger = new Logger(PaymentEventsConsumer.name);
constructor(private readonly paymentsService: PaymentsService) {}
@RabbitSubscribe({
exchange: PAYMENT_EVENTS_EXCHANGE,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*
queue: PASSENGER_QUEUE.main,
queueOptions: {
durable: true,
deadLetterExchange: PAYMENT_EVENTS_DLX,
},
})
async handle(event: PaymentEvent): Promise<Nack | void> {
try {
const result = await this.paymentsService.handlePaymentEvent(
event as unknown as PaymentEventDto,
);
this.logger.log(
`processed ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${JSON.stringify(result)}`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`DEAD-LETTERING ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${message}`,
);
return new Nack(false);
}
}
}

View File

@@ -1,10 +1,50 @@
export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; }
export async function telebirrAdapter(_a: number, ref: string): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 200));
return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } };
export interface GatewayResult {
success: boolean;
providerRef: string;
clientAction?: { type: string; url?: string };
}
export async function telebirrAdapter(
_a: number,
ref: string,
): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 200));
return {
success: true,
providerRef: `TB-${ref}-${Date.now()}`,
clientAction: {
type: "REDIRECT",
url: `https://telebirr.sandbox.com/pay/${ref}`,
},
};
}
export async function cbeBirrAdapter(
_a: number,
ref: string,
): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 150));
return { success: true, providerRef: `CBE-${ref}-${Date.now()}` };
}
export async function eBirrAdapter(
_a: number,
ref: string,
): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 150));
return { success: true, providerRef: `EB-${ref}-${Date.now()}` };
}
export async function cardAdapter(
_a: number,
ref: string,
): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 150));
return {
success: !ref.startsWith("FAIL"),
providerRef: `CARD-${ref}-${Date.now()}`,
};
}
export async function walletAdapter(
amount: number,
balance: number,
): Promise<GatewayResult> {
return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` };
}
export async function cbeBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; }
export async function eBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; }
export async function cardAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; }
export async function walletAdapter(amount: number, balance: number): Promise<GatewayResult> { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; }

View File

@@ -1,108 +1,183 @@
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
import { Response } from 'express';
import { PaymentsService } from './payments.service';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator';
import { UserRole } from '@prisma/client';
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Query,
Res,
UseGuards,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { Response } from "express";
import { PaymentsService } from "./payments.service";
import {
InitiatePaymentDto,
RefundDto,
AddPaymentMethodDto,
PaymentRegionEnum,
SupportedPaymentMethodDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
} from "./payments.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { RolesGuard } from "../../common/roles.guard";
import { Roles } from "../../common/roles.decorator";
import { UserRole } from "@prisma/client";
@ApiTags('Payment')
@Controller('payments')
@ApiTags("Payment")
@Controller("payments")
export class PaymentsController {
constructor(private service: PaymentsService) {}
@Post('initiate')
@ApiOperation({
summary: 'Initiate payment with nationality-based payment methods',
description: `Initiates payment for a booking with support for multiple payment providers:
**Ethiopian Payment Methods:**
- TELEBIRR - Ethiopia's leading mobile money
- CBE_BIRR - Commercial Bank of Ethiopia
- EBIRR - Electronic payment gateway
@Get("all")
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
@ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "status", required: false })
@ApiQuery({ name: "method", required: false })
@ApiQuery({ name: "page", required: false })
@ApiQuery({ name: "pageSize", required: false })
async getAll(
@Query("search") search?: string,
@Query("status") status?: string,
@Query("method") method?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
return this.service.getAll({
search,
status,
method,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
**Djiboutian Payment Methods:**
- WAAFI - Djibouti's mobile money service
**International Payment Methods:**
- CARD - Visa, Mastercard
- WALLET - Internal wallet balance
**Multi-Currency:**
- All transactions processed in ETB
- Display amounts in ETB, DJF, or USD
- Real-time exchange rate conversion`
@Post("initiate")
@ApiOperation({
summary: "Initiate payment with nationality-based payment methods",
description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`,
})
initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@Get('intents/:bookingId')
@ApiOperation({ summary: 'Get payment intent status for a booking' })
getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); }
@Post('refund')
initiatePayment(@Body() dto: InitiatePaymentDto) {
return this.service.initiatePayment(dto);
}
@Get("intents/:bookingId")
@ApiOperation({ summary: "Get payment intent status for a booking" })
getIntent(@Param("bookingId") bookingId: string) {
return this.service.getIntentByBookingId(bookingId);
}
@Post("refund")
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' })
refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
refund(@Body() dto: RefundDto) {
return this.service.refund(dto);
}
@Post('methods')
@Post("methods")
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Add a payment system to the platform catalog (admin only)' })
addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods')
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: 'List payment systems supported by the platform',
description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.',
summary: "Add a payment system to the platform catalog (admin only)",
})
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
addMethod(@Body() dto: AddPaymentMethodDto) {
return this.service.addPaymentMethod(dto);
}
@Get('checkout')
@Get("methods")
@ApiOperation({
summary: 'Browser checkout redirect',
description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.',
summary: "List payment systems supported by the platform",
description:
"Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.",
})
@ApiQuery({ name: 'bookingId', required: true })
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
@ApiProduces('text/html')
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(@Query("region") region?: PaymentRegionEnum) {
return this.service.getSupportedPaymentMethods(region);
}
@Get("checkout")
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query('bookingId') bookingId: string,
@Query('method') method: PaymentMethodTypeEnum,
@Query('platform') platform: PaymentPlatformDto = 'web',
@Query("bookingId") bookingId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId'));
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(
this.buildErrorHtml("Missing required query parameter: bookingId"),
);
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method'));
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(
this.buildErrorHtml("Missing or invalid query parameter: method"),
);
}
try {
const result = await this.service.initiatePayment({ bookingId, method, platform });
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
const result = await this.service.initiatePayment({
bookingId,
method,
platform,
});
const url =
result.clientAction?.type === "REDIRECT"
? result.clientAction.url
: undefined;
if (url) {
return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url));
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildRedirectHtml(url));
}
return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId));
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message));
const message =
err instanceof Error ? err.message : "An unexpected error occurred";
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildErrorHtml(message));
}
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/"/g, '&quot;');
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>

View File

@@ -1,36 +1,53 @@
import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentIntentStatus } from '@prisma/client';
import {
IsString,
IsEnum,
IsOptional,
IsIn,
IsBoolean,
IsInt,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { PaymentIntentStatus } from "@prisma/client";
export enum PaymentRegionEnum {
ETHIOPIA = 'ETHIOPIA',
DJIBOUTI = 'DJIBOUTI',
INTERNATIONAL = 'INTERNATIONAL',
GLOBAL = 'GLOBAL',
ETHIOPIA = "ETHIOPIA",
DJIBOUTI = "DJIBOUTI",
INTERNATIONAL = "INTERNATIONAL",
GLOBAL = "GLOBAL",
}
export enum PaymentMethodTypeEnum {
TELEBIRR = 'TELEBIRR', // Ethiopia
CBE_BIRR = 'CBE_BIRR', // Ethiopia
EBIRR = 'EBIRR', // Ethiopia
WAAFI = 'WAAFI', // Djibouti
CARD = 'CARD', // International
WALLET = 'WALLET' // Internal
export enum PaymentMethodTypeEnum {
TELEBIRR = "TELEBIRR", // Ethiopia
CBE_BIRR = "CBE_BIRR", // Ethiopia
EBIRR = "EBIRR", // Ethiopia
WAAFI = "WAAFI", // Djibouti
CARD = "CARD", // International
WALLET = "WALLET", // Internal
}
export type PaymentPlatformDto = 'web' | 'mobile';
export type PaymentPlatformDto = "web" | "mobile";
export class InitiatePaymentDto {
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
@ApiProperty({
enum: PaymentMethodTypeEnum,
description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)',
example: 'TELEBIRR'
}) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' })
description:
"Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)",
example: "TELEBIRR",
})
@IsEnum(PaymentMethodTypeEnum)
method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: "Saved payment method ID (optional)" })
@IsOptional()
@IsIn(['web', 'mobile'])
@IsString()
paymentMethodId?: string;
@ApiPropertyOptional({
enum: ["web", "mobile"],
default: "web",
description: "Payment platform (web or mobile)",
})
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
}
@@ -40,42 +57,76 @@ export class RefundDto {
}
export class AddPaymentMethodDto {
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
@ApiProperty({ enum: PaymentMethodTypeEnum })
@IsEnum(PaymentMethodTypeEnum)
type: PaymentMethodTypeEnum;
@ApiProperty() @IsString() displayName: string;
@ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum;
@ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string;
@ApiProperty({ enum: PaymentRegionEnum })
@IsEnum(PaymentRegionEnum)
region: PaymentRegionEnum;
@ApiPropertyOptional({ example: "ETB" })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
@ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean;
@ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
enabled?: boolean;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsInt()
sortOrder?: number;
}
export class SupportedPaymentMethodDto {
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
@ApiProperty({ example: 'Telebirr' }) displayName: string;
@ApiProperty({ example: "Telebirr" }) displayName: string;
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
@ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string;
@ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean;
@ApiProperty({
example: "ETB",
description: "Settlement currency for this method",
})
currency: string;
@ApiProperty({
description: "Whether the platform currently accepts this method",
})
enabled: boolean;
}
export class ClientActionDto {
@ApiProperty({ enum: ['REDIRECT', 'LAUNCH_APP'] }) type: 'REDIRECT' | 'LAUNCH_APP';
@ApiPropertyOptional({ description: 'Set when type=REDIRECT (web flow)' }) url?: string;
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) prepayId?: string;
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string;
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string;
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type:
| "REDIRECT"
| "LAUNCH_APP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({
description: "Set when type=LAUNCH_APP (mobile flow)",
})
prepayId?: string;
@ApiPropertyOptional({
description: "Set when type=LAUNCH_APP (mobile flow)",
})
receiveCode?: string;
@ApiPropertyOptional({
description: "Set when type=LAUNCH_APP (mobile flow)",
})
shortCode?: string;
}
export class InitiateResponseDto {
@ApiProperty() intentId: string;
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional() merchantOrderId?: string;
}
export class IntentStatusDto {
@ApiProperty() intentId: string;
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional() merchantOrderId?: string;
@ApiPropertyOptional() paidAt?: string;
@ApiPropertyOptional() failureCode?: string;

View File

@@ -1,10 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../../app.module';
import { PrismaService } from '../../common/prisma.service';
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import request from "supertest";
import { AppModule } from "../../app.module";
import { PrismaService } from "../../common/prisma.service";
describe('Payments E2E', () => {
describe("Payments E2E", () => {
let app: INestApplication;
let prisma: PrismaService;
let authToken: string;
@@ -16,49 +16,123 @@ describe('Payments E2E', () => {
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true }),
);
await app.init();
prisma = app.get<PrismaService>(PrismaService);
const testUser = await prisma.user.create({
data: { email: 'payment-test@example.com', phone: '+251911111112', fullName: 'Payment Test User', passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', role: 'PASSENGER' },
data: {
email: "payment-test@example.com",
phone: "+251911111112",
fullName: "Payment Test User",
passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz",
role: "PASSENGER",
},
});
const passenger = await prisma.passenger.create({ data: { userId: testUser.id } });
const passenger = await prisma.passenger.create({
data: { userId: testUser.id },
});
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000, currency: 'ETB' } });
await prisma.walletAccount.create({
data: {
passengerId: passenger.id,
balanceMinor: 100000,
currency: "ETB",
},
});
authToken = 'mock-jwt-token';
authToken = "mock-jwt-token";
const station1 = await prisma.station.create({ data: { code: 'TST1', name: 'Test Station 1', city: 'Test City', lat: 9.0, lng: 38.0 } });
const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } });
const station1 = await prisma.station.create({
data: {
code: "TST1",
name: "Test Station 1",
city: "Test City",
lat: 9.0,
lng: 38.0,
},
});
const station2 = await prisma.station.create({
data: {
code: "TST2",
name: "Test Station 2",
city: "Test City 2",
lat: 9.5,
lng: 38.5,
},
});
const train = await prisma.train.create({ data: { number: 'TEST-001', name: 'Test Train' } });
const train = await prisma.train.create({
data: { number: "TEST-001", name: "Test Train" },
});
const schedule = await prisma.trainSchedule.create({
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
data: {
trainId: train.id,
originStationId: station1.id,
destinationStationId: station2.id,
departureAt: new Date(Date.now() + 86400000),
arrivalAt: new Date(Date.now() + 90000000),
durationMinutes: 60,
},
});
const seatClass = await prisma.seatClass.upsert({
where: { name: 'Economy Regular' },
update: {},
create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true },
const coachType = await prisma.coachType.create({
data: { name: "Standard", code: "STD" },
});
const seatClass = await prisma.seatClass.create({
data: {
name: "Economy Regular",
description: "Standard economy seating",
baseFareMinor: 45000,
isActive: true,
coachTypeId: coachType.id,
},
});
const coach = await prisma.coach.create({
data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 },
data: {
coachTypeId: coachType.id,
number: "TEST-C1",
arrangement: "2+2",
capacity: 10,
status: "ACTIVE",
},
});
await prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1 } });
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', label: '1A', status: 'AVAILABLE' } });
const seat = await prisma.seat.create({
data: {
coachId: coach.id,
row: 1,
col: "A",
seatNumber: "1A",
status: "AVAILABLE",
},
});
const booking = await prisma.booking.create({
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
data: {
bookingRef: "TEST-BOOK-001",
passengerId: passenger.id,
scheduleId: schedule.id,
status: "PENDING_PAYMENT",
totalMinor: 50000,
currency: "ETB",
},
});
await prisma.bookingSeat.create({ data: { bookingId: booking.id, seatId: seat.id, passengerName: 'Test Passenger' } });
await prisma.bookingSeat.create({
data: {
bookingId: booking.id,
seatId: seat.id,
passengerName: "Test Passenger",
},
});
bookingId = booking.id;
});
@@ -73,89 +147,60 @@ describe('Payments E2E', () => {
prisma.coach.deleteMany(),
prisma.trainSchedule.deleteMany(),
prisma.train.deleteMany(),
prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }),
prisma.station.deleteMany({ where: { code: { in: ["TST1", "TST2"] } } }),
prisma.walletLedgerEntry.deleteMany(),
prisma.walletAccount.deleteMany(),
prisma.passenger.deleteMany(),
prisma.user.deleteMany({ where: { email: 'payment-test@example.com' } }),
prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }),
]);
await app.close();
});
describe('POST /payments/initiate', () => {
it('should initiate wallet payment successfully', async () => {
describe("POST /payments/initiate", () => {
it("should initiate wallet payment successfully", async () => {
const response = await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({ bookingId, method: 'WALLET' })
.post("/payments/initiate")
.set("Authorization", `Bearer ${authToken}`)
.send({ bookingId, method: "WALLET" })
.expect(201);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBe('SUCCEEDED');
expect(response.body.status).toBe("SUCCEEDED");
});
it('should return 400 for invalid payment method', async () => {
it("should return 400 for invalid payment method", async () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({ bookingId, method: 'INVALID_METHOD' })
.post("/payments/initiate")
.set("Authorization", `Bearer ${authToken}`)
.send({ bookingId, method: "INVALID_METHOD" })
.expect(400);
});
it('should return 404 for non-existent booking', async () => {
it("should return 404 for non-existent booking", async () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({ bookingId: 'non-existent-id', method: 'WALLET' })
.post("/payments/initiate")
.set("Authorization", `Bearer ${authToken}`)
.send({ bookingId: "non-existent-id", method: "WALLET" })
.expect(404);
});
});
describe('GET /payments/intents/:bookingId', () => {
it('should get payment intent status', async () => {
describe("GET /payments/intents/:bookingId", () => {
it("should get payment intent status", async () => {
const response = await request(app.getHttpServer())
.get(`/payments/intents/${bookingId}`)
.set('Authorization', `Bearer ${authToken}`)
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBeDefined();
});
it('should return 404 for non-existent intent', async () => {
it("should return 404 for non-existent intent", async () => {
await request(app.getHttpServer())
.get('/payments/intents/non-existent-booking')
.set('Authorization', `Bearer ${authToken}`)
.get("/payments/intents/non-existent-booking")
.set("Authorization", `Bearer ${authToken}`)
.expect(404);
});
});
describe('Webhook endpoints', () => {
it('should handle Telebirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/telebirr')
.send({ merch_order_id: 'TEST-ORDER-123', payment_order_id: 'PAY-123', trade_status: 'Completed', sign: 'mock-signature' })
.expect(200);
});
it('should handle CBE Birr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/cbe-birr')
.send({ merchantId: 'TEST-MERCHANT', merchantOrderId: 'TEST-ORDER-123', orderId: 'CBE-ORDER-123', status: 'SUCCESS', signature: 'mock-signature' })
.expect(200);
});
it('should handle eBirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/ebirr')
.send({ merchantCode: 'TEST-MERCHANT', orderNo: 'TEST-ORDER-123', tradeStatus: 'TRADE_SUCCESS', timestamp: Date.now(), sign: 'mock-signature' })
.expect(200);
});
it('should handle Card webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/card')
.set('stripe-signature', 'mock-signature')
.send({ id: 'evt_123', type: 'payment_intent.succeeded', data: { object: { id: 'pi_123', status: 'succeeded', amount: 50000, currency: 'ETB', metadata: { merchantOrderId: 'TEST-ORDER-123', bookingRef: 'TEST-BOOK-001' } } }, created: Math.floor(Date.now() / 1000) })
.expect(200);
});
});
// Provider webhooks moved to the payment microservice (apps/edr-payment-api /webhooks/*).
});

View File

@@ -1,38 +1,61 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
import {
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
} from '@edr/payment-providers';
import { WebhooksController } from './webhooks/webhooks.controller';
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service';
import { EBirrWebhookService } from './webhooks/ebirr-webhook.service';
import { CardWebhookService } from './webhooks/card-webhook.service';
import { WaafiWebhookService } from './webhooks/waafi-webhook.service';
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
PAYMENT_QUEUES,
PaymentService,
paymentServiceBindingPattern,
} from "@edr/types";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { SeatsModule } from "../seats/seats.module";
import { TicketsModule } from "../tickets/tickets.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
@Module({
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
controllers: [PaymentsController, WebhooksController],
imports: [
SeatsModule,
TicketsModule,
HttpModule.register({ timeout: 10_000 }),
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>("rabbitmq.url") as string,
exchanges: [
{
name: PAYMENT_EVENTS_EXCHANGE,
type: "topic",
options: { durable: true },
},
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
],
queues: [
{
name: PASSENGER_QUEUE.dlq,
exchange: PAYMENT_EVENTS_DLX,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER),
options: { durable: true },
},
],
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
connectionInitOptions: { wait: false },
}),
}),
],
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
WaafiWebhookService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,
],
})
export class PaymentsModule {}

View File

@@ -1,19 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PaymentsService } from './payments.service';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Test, TestingModule } from "@nestjs/testing";
import { PaymentsService } from "./payments.service";
import { PaymentClientService } from "./payment-client.service";
import { PrismaService } from "../../common/prisma.service";
import { SeatsService } from "../seats/seats.service";
import { TicketsService } from "../tickets/tickets.service";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import {
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
} from '@edr/payment-providers';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { BadRequestException, NotFoundException } from '@nestjs/common';
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService as PaymentServiceEnum,
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
describe('PaymentsService', () => {
describe("PaymentsService", () => {
let service: PaymentsService;
let prisma: PrismaService;
let seatsService: SeatsService;
@@ -62,29 +64,25 @@ describe('PaymentsService', () => {
emit: jest.fn(),
};
const mockTelebirrProvider = {
method: PaymentMethodType.TELEBIRR,
const mockPaymentClient = {
initiate: jest.fn(),
queryStatus: jest.fn(),
getIntentByReference: jest.fn(),
};
const mockCbeBirrProvider = {
method: PaymentMethodType.CBE_BIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockEBirrProvider = {
method: PaymentMethodType.EBIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockCardProvider = {
method: PaymentMethodType.CARD,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const requiresActionSnapshot = (
provider: ProviderMethod,
): PaymentIntentSnapshot => ({
intentId: "remote-intent-1",
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
referenceId: "booking-1",
merchantOrderId: "PSG-MERCH-123",
provider,
status: ProviderPaymentStatus.REQUIRES_ACTION,
amountMinor: 50000,
currency: "ETB",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -94,10 +92,7 @@ describe('PaymentsService', () => {
{ provide: SeatsService, useValue: mockSeatsService },
{ provide: TicketsService, useValue: mockTicketsService },
{ provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
{ provide: EBirrProvider, useValue: mockEBirrProvider },
{ provide: CardProvider, useValue: mockCardProvider },
{ provide: PaymentClientService, useValue: mockPaymentClient },
],
}).compile();
@@ -108,221 +103,276 @@ describe('PaymentsService', () => {
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
jest.clearAllMocks();
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
});
describe('initiatePayment', () => {
describe("initiatePayment", () => {
const mockBooking = {
id: 'booking-1',
bookingRef: 'EDR123456',
passengerId: 'passenger-1',
id: "booking-1",
bookingRef: "EDR123456",
passengerId: "passenger-1",
totalMinor: 50000,
currency: 'ETB',
status: 'PENDING_PAYMENT',
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
currency: "ETB",
status: "PENDING_PAYMENT",
seats: [{ id: "seat-1", seatId: "seat-id-1" }],
};
it('should throw NotFoundException if booking not found', async () => {
it("should throw NotFoundException if booking not found", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(null);
await expect(
service.initiatePayment({
bookingId: 'invalid',
method: 'TELEBIRR' as any,
bookingId: "invalid",
method: "TELEBIRR" as any,
}),
).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException if booking not payable', async () => {
it("should throw BadRequestException if booking not payable", async () => {
mockPrisma.booking.findUnique.mockResolvedValue({
...mockBooking,
status: 'CONFIRMED',
status: "CONFIRMED",
});
await expect(
service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
bookingId: "booking-1",
method: "TELEBIRR" as any,
}),
).rejects.toThrow(BadRequestException);
});
it('should initiate Telebirr payment successfully', async () => {
it("should initiate a provider payment through the payment microservice", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockTelebirrProvider.initiate.mockResolvedValue({
providerOrderId: 'TB-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
});
mockPaymentClient.initiate.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
bookingId: "booking-1",
method: "TELEBIRR" as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockTelebirrProvider.initiate).toHaveBeenCalled();
expect(result.clientAction?.url).toBe("https://provider.example/pay");
expect(mockPaymentClient.initiate).toHaveBeenCalledWith(
expect.objectContaining({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
referenceId: "booking-1",
orderRef: "EDR123456",
amountMinor: 50000,
currency: "ETB",
provider: "TELEBIRR",
}),
);
// Snapshot mirrored into the local projection.
expect(mockPrisma.paymentIntent.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { bookingId: "booking-1" } }),
);
});
it('should initiate CBE Birr payment successfully', async () => {
it("should finalize the booking when the service reports an already-paid intent", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockCbeBirrProvider.initiate.mockResolvedValue({
providerOrderId: 'CBE-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
mockPaymentClient.initiate.mockResolvedValue({
...requiresActionSnapshot(ProviderMethod.WAAFI),
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: "TXN-1",
paidAt: new Date().toISOString(),
});
// Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it.
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'CBE_BIRR' as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockCbeBirrProvider.initiate).toHaveBeenCalled();
});
it('should initiate wallet payment and debit successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
balanceMinor: 100000,
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: 'intent-1',
id: "intent-1",
status: PaymentIntentStatus.SUCCEEDED,
bookingId: 'booking-1',
merchantOrderId: "PSG-MERCH-123",
});
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue(null);
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "WAAFI" as any,
});
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
});
it("should initiate wallet payment and debit successfully", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
// First call: existing-intent check (none); second call: finalize loads the new intent.
mockPrisma.paymentIntent.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: "wallet-1",
passengerId: "passenger-1",
balanceMinor: 100000,
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.SUCCEEDED,
bookingId: "booking-1",
});
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
id: "loyalty-1",
pointsBalance: 100,
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
bookingId: "booking-1",
method: "WALLET" as any,
});
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
expect(mockTicketsService.generate).toHaveBeenCalled();
expect(mockPaymentClient.initiate).not.toHaveBeenCalled();
});
it('should fail wallet payment with insufficient balance', async () => {
it("should fail wallet payment with insufficient balance", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
id: "wallet-1",
passengerId: "passenger-1",
balanceMinor: 10000, // Less than booking total
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
id: "intent-1",
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
failureCode: "INSUFFICIENT_BALANCE",
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
bookingId: "booking-1",
method: "WALLET" as any,
});
expect(result.status).toBe(PaymentIntentStatus.FAILED);
});
});
describe('finalizePaymentSuccess', () => {
it('should finalize payment and issue ticket', async () => {
describe("finalizePaymentSuccess", () => {
it("should finalize payment and issue ticket", async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
};
const mockBooking = {
id: 'booking-1',
passengerId: 'passenger-1',
id: "booking-1",
passengerId: "passenger-1",
totalMinor: 50000,
seats: [{ seatId: 'seat-1' }],
seats: [{ seatId: "seat-1" }],
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
id: "loyalty-1",
pointsBalance: 100,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
providerTxnId: 'TXN-123',
intentId: "intent-1",
providerTxnId: "TXN-123",
});
expect(result.alreadyFinalized).toBe(false);
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]);
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", {
booking: mockBooking,
});
});
it('should return alreadyFinalized if payment already succeeded', async () => {
it("should return alreadyFinalized if payment already succeeded", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
id: 'intent-1',
id: "intent-1",
status: PaymentIntentStatus.SUCCEEDED,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
intentId: "intent-1",
});
expect(result.alreadyFinalized).toBe(true);
});
});
describe('getIntentByBookingId', () => {
it('should return intent status', async () => {
describe("getIntentByBookingId", () => {
it("should return the cached local intent when the payment service has none", async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.SUCCEEDED,
method: PaymentMethodType.TELEBIRR,
paidAt: new Date(),
merchantOrderId: 'MERCH-123',
merchantOrderId: "MERCH-123",
updatedAt: new Date(),
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
const result = await service.getIntentByBookingId('booking-1');
const result = await service.getIntentByBookingId("booking-1");
expect(result.intentId).toBe('intent-1');
expect(result.intentId).toBe("intent-1");
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
});
it('should throw NotFoundException if intent not found', async () => {
it("should mirror a payment-service snapshot into the local projection", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPaymentClient.getIntentByReference.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.WAAFI),
);
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
await expect(service.getIntentByBookingId('invalid')).rejects.toThrow(
const result = await service.getIntentByBookingId("booking-1");
expect(mockPaymentClient.getIntentByReference).toHaveBeenCalledWith(
PaymentReferenceType.BOOKING,
"booking-1",
);
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(result.clientAction?.url).toBe("https://provider.example/pay");
});
it("should throw NotFoundException if intent not found anywhere", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
await expect(service.getIntentByBookingId("invalid")).rejects.toThrow(
NotFoundException,
);
});

View File

@@ -1,22 +1,37 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from "@nestjs/common";
import { PrismaService } from "../../common/prisma.service";
import { SeatsService } from "../seats/seats.service";
import { TicketsService } from "../tickets/tickets.service";
import { EventEmitter2 } from "@nestjs/event-emitter";
import {
Prisma,
PaymentIntentStatus,
PaymentMethodType,
PaymentRegion,
} from "@prisma/client";
import {
InitiatePaymentDto,
RefundDto,
AddPaymentMethodDto,
InitiateResponseDto,
IntentStatusDto,
PaymentRegionEnum,
} from "./payments.dto";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentClientService } from "./payment-client.service";
import {
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
PaymentIntentSnapshot,
ProviderMethod,
ClientAction,
PaymentProvider,
ProviderStatus,
ProviderPaymentStatus,
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
createMerchantOrderId,
} from '@edr/payment-providers';
} from "@edr/types";
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
PaymentIntentStatus.REQUIRES_ACTION,
@@ -27,26 +42,67 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly providers: Map<PaymentMethodType, PaymentProvider>;
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
private telebirrProvider: TelebirrProvider,
private cbeBirrProvider: CbeBirrProvider,
private eBirrProvider: EBirrProvider,
private cardProvider: CardProvider,
private waafiProvider: WaafiProvider,
) {
this.providers = new Map<PaymentMethodType, PaymentProvider>([
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
[PaymentMethodType.CBE_BIRR, this.cbeBirrProvider],
[PaymentMethodType.EBIRR, this.eBirrProvider],
[PaymentMethodType.CARD, this.cardProvider],
[PaymentMethodType.WAAFI, this.waafiProvider],
private paymentClient: PaymentClientService,
) {}
async getAll(filters: {
search?: string;
status?: string;
method?: string;
page?: number;
pageSize?: number;
}) {
const { search, status, method, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ id: { contains: search, mode: "insensitive" } },
{ booking: { bookingRef: { contains: search, mode: "insensitive" } } },
];
}
if (status) {
where.status = status;
}
if (method) {
where.method = method;
}
const [items, total] = await Promise.all([
this.prisma.paymentIntent.findMany({
where,
include: { booking: true },
skip,
take: pageSize,
orderBy: { createdAt: "desc" },
}),
this.prisma.paymentIntent.count({ where }),
]);
return {
items: items.map((item) => ({
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: item.booking?.bookingRef },
amountMinor: item.amountMinor,
currency: item.currency,
method: item.method,
status: item.status,
createdAt: item.createdAt,
paidAt: item.paidAt,
})),
total,
page,
pageSize,
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
@@ -54,30 +110,87 @@ export class PaymentsService {
where: { id: dto.bookingId },
include: { seats: true },
});
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'PENDING_PAYMENT') {
throw new BadRequestException('Booking not payable');
}
const existing = await this.prisma.paymentIntent.findUnique({
where: { bookingId: dto.bookingId },
});
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
return this.formatIntentResponse(existing);
if (!booking) throw new NotFoundException("Booking not found");
if (booking.status !== "PENDING_PAYMENT") {
throw new BadRequestException("Booking not payable");
}
const method = dto.method as PaymentMethodType;
// WALLET is an internal balance debit — it never leaves this app.
if (method === PaymentMethodType.WALLET) {
const existing = await this.prisma.paymentIntent.findUnique({
where: { bookingId: dto.bookingId },
});
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
return this.formatIntentResponse(existing);
}
return this.initiateWalletPayment(booking);
}
const provider = this.providers.get(method);
if (provider) {
return this.initiateProviderPayment(booking, provider, dto.platform);
}
// Provider methods go through the payment microservice (docs/payment-service §7.1):
// it owns the intent, the provider session, and the single webhook per provider.
// Re-initiating is safe — the service returns the existing active intent (idempotent).
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
referenceId: booking.id,
orderRef: booking.bookingRef,
amountMinor: booking.totalMinor,
currency: booking.currency,
provider: method as unknown as ProviderMethod,
platform: dto.platform,
// PASSENGER-owned browser bounce-back after the hosted page (freight passes its own).
// UX only — payment is confirmed by the webhook/mark-paid event, never this redirect.
returnUrl: process.env.PAYMENT_RETURN_URL || undefined,
failureUrl: process.env.PAYMENT_FAILURE_URL || undefined,
});
throw new BadRequestException(`Unsupported payment method: ${method}`);
let intent = await this.syncIntentProjection(booking.id, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
// Already-paid order re-initiated: converge the booking now (idempotent).
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
}
return this.formatIntentResponse(intent);
}
private async syncIntentProjection(
bookingId: string,
snapshot: PaymentIntentSnapshot,
) {
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? PaymentIntentStatus.PROCESSING
: (snapshot.status as unknown as PaymentIntentStatus);
const data = {
status,
method: snapshot.provider as unknown as PaymentMethodType,
merchantOrderId: snapshot.merchantOrderId,
clientAction: snapshot.clientAction
? (snapshot.clientAction as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
providerTxnId: snapshot.providerTxnId ?? null,
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
failureCode: snapshot.failureCode ?? null,
failureMessage: snapshot.failureMessage ?? null,
};
return this.prisma.paymentIntent.upsert({
where: { bookingId },
update: data,
create: {
bookingId,
amountMinor: snapshot.amountMinor,
currency: snapshot.currency,
...data,
},
});
}
private async initiateWalletPayment(
@@ -98,7 +211,7 @@ export class PaymentsService {
await tx.walletLedgerEntry.create({
data: {
walletId: wallet.id,
type: 'DEBIT',
type: "DEBIT",
amountMinor: booking.totalMinor,
balanceAfterMinor: newBalance,
description: `Train Ticket - ${booking.bookingRef}`,
@@ -113,14 +226,14 @@ export class PaymentsService {
where: { bookingId: booking.id },
update: {
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
failureCode: "INSUFFICIENT_BALANCE",
},
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method: PaymentMethodType.WALLET,
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
failureCode: "INSUFFICIENT_BALANCE",
},
});
return this.formatIntentResponse(failed);
@@ -144,57 +257,11 @@ export class PaymentsService {
return this.formatIntentResponse(refreshed);
}
private async initiateProviderPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
provider: PaymentProvider,
platform: 'web' | 'mobile' | undefined,
): Promise<InitiateResponseDto> {
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
merchantOrderId,
orderRef: booking.bookingRef,
amountMinor: booking.totalMinor,
currency: booking.currency,
platform,
});
const providerMethod = provider.method as unknown as PaymentMethodType;
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: {
status: PaymentIntentStatus.REQUIRES_ACTION,
method: providerMethod,
merchantOrderId,
providerOrderId: result.providerOrderId,
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
expiresAt: result.expiresAt,
failureCode: null,
failureMessage: null,
},
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
currency: booking.currency,
method: providerMethod,
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId,
providerOrderId: result.providerOrderId,
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
expiresAt: result.expiresAt,
},
});
return this.formatIntentResponse(intent);
}
private formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === 'object'
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
return {
@@ -206,65 +273,52 @@ export class PaymentsService {
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const intent = await this.prisma.paymentIntent.findUnique({
const local = await this.prisma.paymentIntent.findUnique({
where: { bookingId },
});
if (!intent) throw new NotFoundException('PaymentIntent not found');
const refreshable =
intent.status === PaymentIntentStatus.REQUIRES_ACTION ||
intent.status === PaymentIntentStatus.PROCESSING;
const stale = intent.updatedAt.getTime() < Date.now() - 5_000;
const provider = this.providers.get(intent.method);
if (refreshable && stale && intent.merchantOrderId && provider) {
try {
const status = await provider.queryStatus(intent.merchantOrderId);
this.logger.log(status);
await this.applyProviderStatus(intent.id, status);
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentStatus(refreshed);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
);
}
// WALLET payments never leave this app — no remote intent exists for them.
if (local?.method === PaymentMethodType.WALLET) {
return this.formatIntentStatus(local);
}
return this.formatIntentStatus(intent);
}
// Pull/reconcile through the payment microservice (it refreshes stale intents from the
// provider itself). Falls back to the legacy local path when the service is unreachable
// or only a pre-cutover local intent exists.
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
);
}
private async applyProviderStatus(
intentId: string,
status: ProviderStatus,
): Promise<void> {
const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } })
?.biz_content;
if (bizContent?.order_status === 'PAY_SUCCESS') {
if (!snapshot) {
// Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
// status. The payment service owns provider refresh for everything initiated after
// the cutover; webhooks/mark-paid converge the rest.
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
let intent = await this.syncIntentProjection(bookingId, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
// Poll observed success before (or instead of) the mark-paid event — converge now.
await this.finalizePaymentSuccess({
intentId,
providerTxnId: status.providerTxnId,
intentId: intent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
return;
}
if (status.status === ProviderPaymentStatus.FAILED) {
await this.markPaymentFailed({
intentId,
failureCode: status.failureCode,
failureMessage: status.failureMessage,
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return;
}
await this.prisma.paymentIntent.update({
where: { id: intentId },
data: {
status: status.status as unknown as PaymentIntentStatus,
providerTxnId: status.providerTxnId ?? undefined,
},
});
return this.formatIntentStatus(intent);
}
private formatIntentStatus(
@@ -280,13 +334,25 @@ export class PaymentsService {
}
async refund(dto: RefundDto) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } });
if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');
await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } });
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
const intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: dto.bookingId },
});
if (!intent || intent.status !== "SUCCEEDED")
throw new BadRequestException("No successful payment to refund");
await this.prisma.paymentIntent.update({
where: { bookingId: dto.bookingId },
data: { status: "CANCELLED" },
});
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
include: { seats: true },
});
if (booking) {
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } });
await this.prisma.booking.update({
where: { id: dto.bookingId },
data: { status: "CANCELLED" },
});
}
return { refunded: true, bookingRef: booking?.bookingRef };
}
@@ -296,7 +362,7 @@ export class PaymentsService {
type: dto.type as unknown as PaymentMethodType,
displayName: dto.displayName,
region: dto.region as unknown as PaymentRegion,
currency: dto.currency ?? 'ETB',
currency: dto.currency ?? "ETB",
providerId: dto.providerId,
enabled: dto.enabled ?? true,
sortOrder: dto.sortOrder ?? 0,
@@ -313,10 +379,17 @@ export class PaymentsService {
where: {
enabled: true,
...(region
? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } }
? {
region: {
in: [
region,
PaymentRegionEnum.GLOBAL,
] as unknown as PaymentRegion[],
},
}
: {}),
},
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
});
}
@@ -328,19 +401,21 @@ export class PaymentsService {
const intent = await this.prisma.paymentIntent.findUnique({
where: { id: input.intentId },
});
if (!intent) throw new NotFoundException('PaymentIntent not found');
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
return { alreadyFinalized: true };
}
if (intent.status === PaymentIntentStatus.CANCELLED) {
throw new BadRequestException('PaymentIntent is cancelled; cannot finalize');
throw new BadRequestException(
"PaymentIntent is cancelled; cannot finalize",
);
}
const booking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId },
include: { seats: true },
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) throw new NotFoundException("Booking not found");
const paidAt = input.paidAt ?? new Date();
await this.prisma.$transaction(async (tx) => {
@@ -348,23 +423,134 @@ export class PaymentsService {
where: { id: intent.id },
data: {
status: PaymentIntentStatus.SUCCEEDED,
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined,
providerTxnId:
input.providerTxnId ?? intent.providerTxnId ?? undefined,
paidAt,
},
});
await tx.booking.update({
where: { id: booking.id },
data: { status: 'CONFIRMED' },
data: { status: "CONFIRMED" },
});
});
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.ticketsService.generate(booking.id);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
this.eventEmitter.emit('payment.succeeded', { booking });
try {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
} catch (err) {
this.logger.error(
`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
await this.createJourneySegments(booking);
} catch (err) {
this.logger.error(
`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
await this.ticketsService.generate(booking.id);
} catch (err) {
this.logger.error(
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
);
throw err;
}
try {
await this.awardLoyaltyPoints(
booking.passengerId,
booking.totalMinor,
booking.id,
);
} catch (err) {
this.logger.warn(
`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`,
);
}
this.eventEmitter.emit("payment.succeeded", { booking });
return { alreadyFinalized: false };
}
async handlePaymentEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (
event.service !== PaymentServiceEnum.PASSENGER ||
event.referenceType !== PaymentReferenceType.BOOKING
) {
this.logger.warn(
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.eventType === "payment.failed") {
const intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },
});
if (intent) {
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
}
return { processed: true };
}
const booking = await this.prisma.booking.findUnique({
where: { id: event.referenceId },
});
if (!booking) {
// Ack (200) — a missing booking will not appear on redelivery; needs investigation.
this.logger.error(
`mark-paid: no booking for reference ${event.referenceId}`,
);
return { processed: false, reason: "booking-not-found" };
}
if (booking.totalMinor !== event.amountMinor) {
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
// which is the alertable signal for an asserted-vs-paid amount divergence.
this.logger.error(
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`,
);
throw new BadRequestException(
"Event amount does not match booking total",
);
}
// Local intent row is a projection during the strangler migration: reuse it when the
// legacy initiate path created one, otherwise materialize it from the event.
let intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },
});
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
bookingId: event.referenceId,
amountMinor: event.amountMinor,
currency: event.currency,
method: event.provider as unknown as PaymentMethodType,
status: PaymentIntentStatus.PROCESSING,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
},
});
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
});
return { processed: true, alreadyFinalized };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
@@ -373,7 +559,7 @@ export class PaymentsService {
const intent = await this.prisma.paymentIntent.findUnique({
where: { id: input.intentId },
});
if (!intent) throw new NotFoundException('PaymentIntent not found');
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (
intent.status === PaymentIntentStatus.SUCCEEDED ||
intent.status === PaymentIntentStatus.CANCELLED
@@ -390,13 +576,93 @@ export class PaymentsService {
});
}
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
private async awardLoyaltyPoints(
passengerId: string,
amountMinor: number,
bookingId: string,
) {
const points = Math.floor(amountMinor / 100);
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
const account = await this.prisma.loyaltyAccount.findUnique({
where: { passengerId },
});
if (!account) return;
const newBalance = account.pointsBalance + points;
const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE';
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
const tier =
newBalance >= 10000
? "PLATINUM"
: newBalance >= 5000
? "GOLD"
: newBalance >= 2000
? "SILVER"
: "BRONZE";
await this.prisma.loyaltyAccount.update({
where: { passengerId },
data: { pointsBalance: { increment: points }, tier: tier as any },
});
await this.prisma.loyaltyLedgerEntry.create({
data: {
accountId: account.id,
delta: points,
reason: "TRIP_COMPLETED",
bookingId,
balanceAfter: newBalance,
},
});
}
private async createJourneySegments(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: booking.scheduleId },
include: {
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
},
});
if (!schedule) return;
const stopTimes = schedule.stopTimes;
if (stopTimes.length < 2) return;
const originSequence = stopTimes.findIndex(
(st) => st.stationId === schedule.originStationId,
);
const destSequence = stopTimes.findIndex(
(st) => st.stationId === schedule.destinationStationId,
);
if (
originSequence < 0 ||
destSequence < 0 ||
originSequence >= destSequence
)
return;
const journey = await this.prisma.journey.create({
data: {
passengerId: booking.passengerId,
status: "CONFIRMED",
totalMinor: booking.totalMinor,
currency: booking.currency,
},
});
const journeySegments = [];
for (const bookingSeat of booking.seats) {
for (let i = originSequence; i < destSequence; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: booking.scheduleId,
segmentOrder: i,
seatId: bookingSeat.seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
}
if (journeySegments.length > 0) {
await this.prisma.journeySegment.createMany({ data: journeySegments });
}
}
}

View File

@@ -1,5 +1,6 @@
// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers).
// This file remains as a thin re-export so existing local imports keep working.
// The payment provider contract lives in @edr/types; the gateways themselves now run only
// inside apps/edr-payment-api. This file remains as a thin re-export so existing local
// imports keep working.
export type {
PaymentProvider,
ProviderInitiationInput,
@@ -7,5 +8,5 @@ export type {
ProviderStatus,
ClientAction,
PaymentPlatform,
} from '@edr/types';
export { ProviderPaymentStatus, ProviderMethod } from '@edr/types';
} from "@edr/types";
export { ProviderPaymentStatus, ProviderMethod } from "@edr/types";

View File

@@ -1,129 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
CardProvider,
CardWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
@Injectable()
export class CardWebhookService {
private readonly logger = new Logger(CardWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: CardProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
const merchantOrderId = payload.data.object.metadata.merchantOrderId;
const externalEventId = `${payload.id}_${payload.type}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
signature,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.data.object.transaction_id,
signatureValid,
status: payload.data.object.status,
payload,
});
if (!eventRow) {
this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
try {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.data.object.transaction_id,
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
});
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.data.object.failure_code,
failureMessage: payload.data.object.failure_message,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.data.object.transaction_id ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: CardWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.CARD,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -1,127 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
CbeBirrProvider,
CbeBirrWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
@Injectable()
export class CbeBirrWebhookService {
private readonly logger = new Logger(CbeBirrWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: CbeBirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.merchantOrderId;
const externalEventId = `${payload.orderId}_${payload.status}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.transactionId ?? payload.orderId,
signatureValid,
status: payload.status,
payload,
});
if (!eventRow) {
this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.status);
try {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.transactionId ?? payload.orderId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
});
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.status,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.transactionId ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: CbeBirrWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.CBE_BIRR,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -1,127 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
EBirrProvider,
EBirrWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
@Injectable()
export class EBirrWebhookService {
private readonly logger = new Logger(EBirrWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: EBirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: EBirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.orderNo;
const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.tradeNo,
signatureValid,
status: payload.tradeStatus,
payload,
});
if (!eventRow) {
this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`);
return;
}
if (!signatureValid) {
this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return;
}
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
try {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.tradeNo,
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
});
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.tradeStatus,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.tradeNo ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: EBirrWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.EBIRR,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -1,149 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
TelebirrProvider,
TelebirrWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
@Injectable()
export class TelebirrWebhookService {
private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly prisma: PrismaService,
private readonly provider: TelebirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: TelebirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.merch_order_id;
const externalEventId = this.buildExternalEventId(payload);
// TODO: re-enable Telebirr public-key signature verification — skipped for now
// const signatureValid = this.provider.verifyWebhookSignature(
// payload as unknown as Record<string, unknown>,
// );
const signatureValid = true;
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: payload.trans_id ?? payload.payment_order_id,
signatureValid,
status: payload.trade_status,
payload,
});
if (!eventRow) {
this.logger.log(
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
);
return;
}
// TODO: re-enable signature gate once verifyWebhookSignature is restored
// if (!signatureValid) {
// this.logger.warn(
// `Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`,
// );
// await this.markProcessed(eventRow.id, 'signature-invalid');
// return;
// }
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
);
await this.markProcessed(eventRow.id, 'intent-not-found');
return;
}
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
try {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.trans_id ?? payload.payment_order_id,
paidAt: this.parseEpochSeconds(payload.trans_end_time),
});
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.trade_status,
});
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId: payload.trans_id ?? undefined,
},
});
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
return `${payload.payment_order_id}_${payload.trade_status}`;
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: TelebirrWebhookPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.TELEBIRR,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
private parseEpochSeconds(raw: string | undefined): Date | undefined {
if (!raw) return undefined;
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return undefined;
return new Date(n * 1000);
}
}

View File

@@ -1,93 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import {
WaafiProvider,
WaafiWebhookPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
@Injectable()
export class WaafiWebhookService {
private readonly logger = new Logger(WaafiWebhookService.name);
constructor(
private prisma: PrismaService,
private paymentsService: PaymentsService,
private waafiProvider: WaafiProvider,
) {}
async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> {
this.logger.log(
`Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`,
);
const signatureValid = this.waafiProvider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const merchantOrderId = payload.params?.referenceId;
const transactionId = payload.params?.transactionId;
const state = payload.params?.state;
await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.WAAFI,
externalEventId: payload.requestId,
merchantOrderId,
providerTxnId: transactionId,
signatureValid,
status: state || 'UNKNOWN',
payload: payload as any,
},
});
if (!signatureValid) {
this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`);
return { received: true };
}
if (!merchantOrderId) {
this.logger.error('Waafi webhook missing referenceId');
return { received: true };
}
const intent = await this.prisma.paymentIntent.findFirst({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`);
return { received: true };
}
const mappedStatus = this.waafiProvider.mapState(state);
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) {
await this.paymentsService.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: transactionId,
});
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`);
} else if (mappedStatus === ProviderPaymentStatus.FAILED) {
await this.paymentsService.markPaymentFailed({
intentId: intent.id,
failureCode: state,
failureMessage: payload.params?.description,
});
this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`);
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mappedStatus as unknown as PaymentIntentStatus,
providerTxnId: transactionId,
},
});
this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`);
}
return { received: true };
}
}

View File

@@ -1,115 +0,0 @@
import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
TelebirrWebhookPayload,
CbeBirrWebhookPayload,
EBirrWebhookPayload,
CardWebhookPayload,
} from '@edr/payment-providers';
import { TelebirrWebhookService } from './telebirr-webhook.service';
import { CbeBirrWebhookService } from './cbe-birr-webhook.service';
import { EBirrWebhookService } from './ebirr-webhook.service';
import { CardWebhookService } from './card-webhook.service';
import { WaafiWebhookService } from './waafi-webhook.service';
@ApiTags('Payment Webhooks')
@Controller('payments/webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(
private readonly telebirr: TelebirrWebhookService,
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
private readonly waafi: WaafiWebhookService,
) {}
@All('telebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Telebirr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
})
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
this.logger.log(
`Telebirr webhook Called`,
);
try {
await this.telebirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);
}
return { code: '0', message: 'OK' };
}
@Post('cbe-birr')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'CBE Birr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for Commercial Bank of Ethiopia payment status updates.'
})
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
try {
await this.cbeBirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`CBE Birr webhook handler threw: ${message}`);
}
return { success: true };
}
@Post('ebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'eBirr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for eBirr electronic payment gateway status updates.'
})
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`eBirr webhook handler threw: ${message}`);
}
return { code: '0000', message: 'success' };
}
@Post('card')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Card payment notification callback (International)',
description: 'Webhook endpoint for international card payments (Visa, Mastercard) via Stripe.'
})
async receiveCard(
@Body() payload: CardWebhookPayload,
@Headers('stripe-signature') signature: string,
) {
try {
await this.card.handle(payload, signature);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Card webhook handler threw: ${message}`);
}
return { received: true };
}
@Post('waafi')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Waafi payment notification callback (Djibouti)',
description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.'
})
async receiveWaafi(@Body() payload: any) {
try {
await this.waafi.handleWebhook(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Waafi webhook handler threw: ${message}`);
}
return { responseCode: '2001', responseMsg: 'Success' };
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, UseGuards, Query, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PromosService } from './promos.service';
import { CreatePromotionDto } from './promos.dto';
@@ -8,7 +8,66 @@ import { JwtGuard } from '../../common/jwt.guard';
@Controller('promos')
export class PromosController {
constructor(private service: PromosService) {}
@Get() @ApiOperation({ summary: 'Get active promotions' }) getActive() { return this.service.getActive(); }
@Get('validate/:code') @ApiOperation({ summary: 'Validate a promo code' }) validate(@Param('code') code: string) { return this.service.validate(code); }
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create promotion (admin)' }) create(@Body() dto: CreatePromotionDto) { return this.service.create(dto); }
@Get()
@ApiOperation({ summary: 'Get active promotions' })
getActive() {
return this.service.getActive();
}
@Get('all')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all promos with filters (admin)' })
getAll(
@Query('search') search?: string,
@Query('active') active?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getAll({
search,
active: active === 'true' ? true : active === 'false' ? false : undefined,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Get(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get promo by ID' })
getById(@Param('id') id: string) {
return this.service.getById(id);
}
@Get('validate/:code')
@ApiOperation({ summary: 'Validate a promo code' })
validate(@Param('code') code: string) {
return this.service.validate(code);
}
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create promotion (admin)' })
create(@Body() dto: CreatePromotionDto) {
return this.service.create(dto);
}
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update promo (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePromotionDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete promo (admin)' })
delete(@Param('id') id: string) {
return this.service.delete(id);
}
}

View File

@@ -1,13 +1,46 @@
import { IsString, IsOptional, IsInt } from 'class-validator';
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePromotionDto {
@ApiProperty({ example: 'Weekend Special' }) @IsString() title: string;
@ApiPropertyOptional({ example: '15% off all routes' }) @IsOptional() @IsString() subtitle?: string;
@ApiProperty({ example: 'WEEKEND15' }) @IsString() code: string;
@ApiPropertyOptional({ example: 15 }) @IsOptional() @IsInt() percentOff?: number;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' }) @IsString() validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' }) @IsOptional() @IsString() ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' }) @IsOptional() @IsString() deepLink?: string;
@ApiProperty({ example: 'SUMMER2024' })
@IsString()
code: string;
@ApiProperty({ example: 'Summer Discount' })
@IsString()
title: string;
@ApiPropertyOptional({ example: 'Get 15% off' })
@IsOptional()
@IsString()
subtitle?: string;
@ApiPropertyOptional({ example: 15 })
@IsOptional()
@IsInt()
percentOff?: number;
@ApiPropertyOptional({ example: 5000 })
@IsOptional()
@IsInt()
amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' })
@IsString()
validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' })
@IsOptional()
@IsString()
ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' })
@IsOptional()
@IsString()
deepLink?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

@@ -1,20 +1,190 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreatePromotionDto } from './promos.dto';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
@Injectable()
export class PromosService {
constructor(private prisma: PrismaService) {}
getActive() { return this.prisma.promotion.findMany({ where: { active: true, validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
getActive() {
return this.prisma.promotion.findMany({
where: { active: true, validUntil: { gte: new Date() } },
orderBy: { createdAt: 'desc' },
});
}
async getAll(filters: { search?: string; active?: boolean; page?: number; pageSize?: number }) {
const { search, active, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ code: { contains: search, mode: 'insensitive' } },
{ title: { contains: search, mode: 'insensitive' } },
];
}
if (active !== undefined) {
where.active = active;
}
const [items, total] = await Promise.all([
this.prisma.promotion.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.promotion.count({ where }),
]);
return { items: this.formatItems(items), total, page, pageSize };
}
async getById(id: string) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
return this.formatItem(promo);
}
async validate(code: string) {
const promo = await this.prisma.promotion.findUnique({ where: { code } });
if (!promo || !promo.active || promo.validUntil < new Date()) return { applicable: false, message: 'Promo code invalid or expired' };
return { code: promo.code, percentOff: promo.percentOff, amountOffMinor: promo.amountOffMinor, validUntil: promo.validUntil, applicable: true, message: promo.percentOff ? `${promo.percentOff}% off` : `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off` };
if (!promo || !promo.active || promo.validUntil < new Date())
return { applicable: false, message: 'Promo code invalid or expired' };
return {
code: promo.code,
percentOff: promo.percentOff,
amountOffMinor: promo.amountOffMinor,
validUntil: promo.validUntil,
applicable: true,
message: promo.percentOff
? `${promo.percentOff}% off`
: `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off`,
};
}
create(dto: CreatePromotionDto) {
return this.prisma.promotion.create({ data: { ...dto, validUntil: new Date(dto.validUntil) } });
async create(dto: CreatePromotionDto & { discountType?: string; discountValue?: number }) {
try {
// Map frontend fields to database fields
let percentOff: number | undefined;
let amountOffMinor: number | undefined;
if (dto.discountType && dto.discountValue !== undefined) {
if (dto.discountType === 'PERCENTAGE') {
percentOff = dto.discountValue;
} else if (dto.discountType === 'FIXED') {
amountOffMinor = dto.discountValue;
}
} else {
// Fallback to direct fields
percentOff = dto.percentOff;
amountOffMinor = dto.amountOffMinor;
}
const promo = await this.prisma.promotion.create({
data: {
code: dto.code,
title: dto.title,
subtitle: dto.subtitle,
percentOff,
amountOffMinor,
validUntil: new Date(dto.validUntil),
ctaLabel: dto.ctaLabel,
deepLink: dto.deepLink,
active: dto.active ?? true,
},
});
return this.formatItem(promo);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
const field = (error.meta?.target as string[])?.[0];
throw new BadRequestException(
`A promo code with this ${field} already exists. Please use a different ${field}.`,
);
}
}
throw error;
}
}
async update(id: string, dto: Partial<CreatePromotionDto> & { discountType?: string; discountValue?: number }) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
const updateData: any = {};
// Map frontend fields to database fields
if (dto.discountType && dto.discountValue !== undefined) {
// Clear existing discount fields
updateData.percentOff = null;
updateData.amountOffMinor = null;
if (dto.discountType === 'PERCENTAGE') {
updateData.percentOff = dto.discountValue;
} else if (dto.discountType === 'FIXED') {
updateData.amountOffMinor = dto.discountValue;
}
} else {
// Only include fields that are explicitly provided
if (dto.percentOff !== undefined) updateData.percentOff = dto.percentOff;
if (dto.amountOffMinor !== undefined) updateData.amountOffMinor = dto.amountOffMinor;
}
if (dto.title !== undefined) updateData.title = dto.title;
if (dto.subtitle !== undefined) updateData.subtitle = dto.subtitle;
if (dto.ctaLabel !== undefined) updateData.ctaLabel = dto.ctaLabel;
if (dto.deepLink !== undefined) updateData.deepLink = dto.deepLink;
if (dto.active !== undefined) updateData.active = dto.active;
if (dto.validUntil !== undefined) updateData.validUntil = new Date(dto.validUntil);
// Don't allow updating code - it's immutable after creation
try {
const updated = await this.prisma.promotion.update({
where: { id },
data: updateData,
});
return this.formatItem(updated);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {
const field = (error.meta?.target as string[])?.[0];
throw new BadRequestException(
`A promo code with this ${field} already exists. Please use a different ${field}.`,
);
}
throw error;
}
}
async delete(id: string) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
return this.prisma.promotion.delete({ where: { id } });
}
private formatItem(promo: any) {
return {
id: promo.id,
code: promo.code,
title: promo.title,
discountType: promo.percentOff ? 'PERCENTAGE' : 'FIXED',
discountValue: promo.percentOff || promo.amountOffMinor || 0,
maxDiscount: undefined,
minBookingAmount: undefined,
maxUsagePerUser: undefined,
totalUsageLimit: undefined,
usageCount: 0,
validFrom: promo.createdAt,
validUntil: promo.validUntil,
isActive: promo.active,
createdAt: promo.createdAt,
updatedAt: promo.createdAt,
};
}
private formatItems(promos: any[]) {
return promos.map((promo) => this.formatItem(promo));
}
}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { TripStatus } from '@prisma/client';
@@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client';
export class SchedulesController {
constructor(private service: SchedulesService) {}
@Post('bulk-generate')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Bulk generate repetitive schedules',
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
})
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create a train schedule from a route template',
description: `Creates a schedule by referencing a Route (routeId).
Stops are automatically copied from the route's RouteStop definitions.
You supply the actual planned arrival/departure times per stop sequence.
Origin and destination are derived from the first and last route stop — no need to specify them manually.`,
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
})
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
@@ -40,13 +49,42 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.listSchedules({ date, routeId, trainId, status });
}
// Static routes before parameterised ones
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Post('segment-fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
@Get('routes/:routeId/segment-fares')
@ApiOperation({ summary: 'List all segment fare rules for a route' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
@Get(':id')
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -56,12 +94,12 @@ Origin and destination are derived from the first and last route stop — no nee
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a schedule' })
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Schedule updated' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
return this.service.updateSchedule(id, dto);
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
return this.service.updateSchedulePartial(id, dto);
}
@Patch(':id/status')
@@ -84,8 +122,6 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.deleteSchedule(id);
}
// ── Stop Times ─────────────────────────────────────────────────────────────
@Get(':id/stops')
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -106,8 +142,6 @@ Origin and destination are derived from the first and last route stop — no nee
@Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); }
// ── Fares ──────────────────────────────────────────────────────────────────
@Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@@ -151,8 +185,6 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.syncFaresFromEngine(id);
}
// ── Coach Assignments ──────────────────────────────────────────────────────
@Post(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({

View File

@@ -34,6 +34,13 @@ export class CreateScheduleDto {
plannedTimes: PlannedStopTimeDto[];
}
export class UpdateScheduleDto {
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
}
export class UpdateStopTimeDto {
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
@@ -50,6 +57,17 @@ export class CreateFareRuleDto {
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}
export class CreateSegmentFareRuleDto {
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
@ApiProperty({ example: 1, description: 'Origin stop sequence number' }) @IsInt() @Min(1) originStopSequence: number;
@ApiProperty({ example: 2, description: 'Destination stop sequence number' }) @IsInt() @Min(1) destinationStopSequence: number;
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}
export class ListSchedulesDto {
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
@IsOptional() @IsDateString() date?: string;
@@ -67,3 +85,21 @@ export class ListSchedulesDto {
export class UpdateScheduleStatusDto {
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
}
export class BulkCreateSchedulesDto {
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Start date and time for first schedule' }) @IsDateString() startDateTime: string;
@ApiProperty({ example: 12, description: 'Hours duration per schedule' }) @IsInt() @Min(1) durationHours: number;
@ApiProperty({ example: 2, description: 'Repeat every X days' }) @IsInt() @Min(1) repeatEveryDays: number;
@ApiProperty({ example: 30, description: 'Generate schedules for the next Y days' }) @IsInt() @Min(1) forNextDays: number;
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
}
export class BulkSchedulesResponseDto {
@ApiProperty() schedulesCreated: number;
@ApiProperty() errors: string[];
@ApiProperty() scheduleIds: string[];
}

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
@Injectable()
export class SchedulesService {
@@ -10,9 +10,55 @@ export class SchedulesService {
private prisma: PrismaService,
private routesService: RoutesService,
private fareEngine: FareEngineService,
) {}
) { }
// ── Schedule CRUD ──────────────────────────────────────────────────────────
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
const startDate = new Date(dto.startDateTime);
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
const errors: string[] = [];
const scheduleIds: string[] = [];
// Validate route and get stops for plannedTimes generation
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (!route) throw new NotFoundException('Route not found');
if (!route.active) throw new BadRequestException('Route is not active');
let currentDate = new Date(startDate);
let scheduleCount = 0;
while (currentDate < endDate) {
try {
const departureAt = new Date(currentDate);
const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000);
const createDto: CreateScheduleDto = {
trainId: dto.trainId,
routeId: dto.routeId,
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
plannedTimes: dto.plannedTimes || [],
};
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
}
// Move to next repetition
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
}
return {
schedulesCreated: scheduleCount,
errors,
scheduleIds,
};
}
async listSchedules(dto: ListSchedulesDto) {
const where: any = {};
@@ -58,28 +104,48 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Check for duplicate schedule with same train, route, and date
const depDate = new Date(dep);
depDate.setHours(0, 0, 0, 0);
const nextDay = new Date(depDate);
nextDay.setDate(nextDay.getDate() + 1);
const existingSchedule = await this.prisma.trainSchedule.findFirst({
where: {
trainId: dto.trainId,
routeId: dto.routeId,
departureAt: {
gte: depDate,
lt: nextDay,
},
},
});
if (existingSchedule) {
throw new BadRequestException(
`A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`,
);
}
// Auto-generate plannedTimes if not provided or empty
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
// First stop - use departure time
stopTime = dep;
} else if (index === route.stops.length - 1) {
// Last stop - use arrival time
stopTime = arr;
} else {
// Intermediate stops - calculate based on distance proportion
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -113,7 +179,6 @@ export class SchedulesService {
include: { train: true, originStation: true, destinationStation: true },
});
// Copy route stops into TripStopTime with the provided planned times
const plannedTimesMap = Object.fromEntries(
plannedTimes.map(t => [t.sequence, t]),
);
@@ -130,7 +195,7 @@ export class SchedulesService {
originStation: true,
destinationStation: true,
coachAssignments: {
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
orderBy: { positionNumber: 'asc' },
},
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
@@ -138,18 +203,16 @@ export class SchedulesService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
// Compute effective seat statuses from SeatHold + JourneySegment
// (seat.status DB column is no longer written during booking)
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
return {
...schedule,
coachAssignments: schedule.coachAssignments.map(a => ({
coachAssignments: schedule.coachAssignments.map((a: any) => ({
...a,
coach: {
...a.coach,
seats: a.coach.seats.map(s => ({
seats: a.coach.seats.map((s: any) => ({
...s,
status: effectiveStatuses.get(s.id) ?? s.status,
})),
@@ -158,12 +221,6 @@ export class SchedulesService {
};
}
/**
* Computes effective seat status for a schedule by checking active SeatHolds
* and confirmed JourneySegments. The DB seat.status column is not written
* during segment-based booking, so this overlay is required.
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
*/
private async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
@@ -204,7 +261,6 @@ export class SchedulesService {
const arr = new Date(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
// Validate route exists and has stops
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
@@ -213,7 +269,6 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Derive origin and destination from first and last route stop
const firstStop = route.stops[0];
const lastStop = route.stops[route.stops.length - 1];
@@ -231,18 +286,16 @@ export class SchedulesService {
},
});
// Delete existing stop times and recreate
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
// Auto-generate plannedTimes if not provided
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
@@ -252,7 +305,7 @@ export class SchedulesService {
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -276,19 +329,53 @@ export class SchedulesService {
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Delete related records first (in dependency order)
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: id },
select: { id: true },
});
const bookingIds = bookings.map(b => b.id);
if (bookingIds.length > 0) {
const paymentIntents = await this.prisma.paymentIntent.findMany({
where: { bookingId: { in: bookingIds } },
select: { id: true },
});
const paymentIntentIds = paymentIntents.map(pi => pi.id);
if (paymentIntentIds.length > 0) {
await this.prisma.paymentRefund.deleteMany({
where: { paymentIntentId: { in: paymentIntentIds } },
});
}
await this.prisma.ticket.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingSeat.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingModification.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingCancellation.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.paymentIntent.deleteMany({
where: { bookingId: { in: bookingIds } },
});
}
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } });
}
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
getStops(scheduleId: string) {
return this.prisma.tripStopTime.findMany({
where: { scheduleId },
@@ -314,8 +401,6 @@ export class SchedulesService {
});
}
// ── Fare Rules ─────────────────────────────────────────────────────────────
createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
return this.prisma.fareRule.create({
@@ -329,6 +414,43 @@ export class SchedulesService {
});
}
createSegmentFareRule(dto: any) {
const { validFrom, validUntil, ...rest } = dto;
return this.prisma.segmentFareRule.create({
data: {
...rest,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
getSegmentFares(routeId: string) {
return this.prisma.segmentFareRule.findMany({
where: { routeId },
include: { seatClass: true, route: true },
orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
});
}
deleteSegmentFareRule(id: string) {
return this.prisma.segmentFareRule.delete({ where: { id } });
}
updateSegmentFareRule(id: string, dto: any) {
const { validFrom, validUntil, ...rest } = dto;
return this.prisma.segmentFareRule.update({
where: { id },
data: {
...rest,
validFrom: validFrom ? new Date(validFrom) : undefined,
validUntil: validUntil ? new Date(validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
}
@@ -337,10 +459,6 @@ export class SchedulesService {
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
}
/**
* Recalculate fares for all active seat classes on a schedule using the fare engine
* and upsert them as FareRule records scoped to this schedule.
*/
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
const errors: string[] = [];
@@ -352,7 +470,6 @@ export class SchedulesService {
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
// Expire any existing active rule for this schedule + seat class
await this.prisma.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
data: { validUntil: now },
@@ -377,8 +494,6 @@ export class SchedulesService {
return { synced, errors };
}
// ── Coach Assignments ──────────────────────────────────────────────────────
async assignCoaches(
scheduleId: string,
coaches: Array<{ coachId: string; positionNumber: number }>,
@@ -386,7 +501,6 @@ export class SchedulesService {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Validate all coaches exist
const coachIds = coaches.map(c => c.coachId);
const existingCoaches = await this.prisma.coach.findMany({
where: { id: { in: coachIds } },
@@ -395,18 +509,16 @@ export class SchedulesService {
throw new NotFoundException('One or more coaches not found');
}
// Remove existing assignments
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
// Create new assignments
await this.prisma.coachAssignment.createMany({
data: coaches.map(c => ({
scheduleId,
coachId: c.coachId,
positionNumber: c.positionNumber,
isOperational: true,
})),
});
const data = coaches.map((c, idx) => ({
scheduleId,
coachId: c.coachId,
positionNumber: idx + 1,
isOperational: true,
}));
await this.prisma.coachAssignment.createMany({ data });
return { message: 'Coaches assigned successfully', count: coaches.length };
}
@@ -417,7 +529,6 @@ export class SchedulesService {
include: {
coach: {
include: {
seatClass: true,
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
},
},
@@ -426,6 +537,41 @@ export class SchedulesService {
});
}
async updateSchedulePartial(id: string, dto: UpdateScheduleDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const updateData: any = {};
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
updateData.departureAt = dep;
updateData.arrivalAt = arr;
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
}
if (dto.status) {
updateData.status = dto.status;
}
if (Object.keys(updateData).length > 0) {
await this.prisma.trainSchedule.update({
where: { id },
data: updateData,
});
}
if (dto.coaches && dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);
}
return this.getSchedule(id);
}
async removeCoachAssignment(scheduleId: string, coachId: string) {
const assignment = await this.prisma.coachAssignment.findFirst({
where: { scheduleId, coachId },
@@ -435,4 +581,4 @@ export class SchedulesService {
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' };
}
}
}

View File

@@ -22,8 +22,6 @@ export class SearchService {
const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
// Find all schedules that have BOTH origin and destination as stops
// (not just terminal-to-terminal) and depart on the requested date
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
@@ -36,7 +34,7 @@ export class SearchService {
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, seatClass: true } } },
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
});
@@ -44,36 +42,71 @@ export class SearchService {
const results = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
// Both stops must exist and origin must come before destination
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
// Compute per-seat availability for the requested segment range
// A seat is available if no active booking/hold overlaps [originSeq, destSeq)
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const className = assignment.coach.seatClass.name;
if (!availabilityByClass[className]) availabilityByClass[className] = 0;
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
// Use segment-aware check — a seat booked A→B is still free for B→D
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availabilityByClass[className]++;
if (isBedCoach) {
const bedPositions = ['upper', 'middle', 'lower'];
for (const bedPosition of bedPositions) {
let count = 0;
for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition) continue;
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((className: string) => {
const classNameLower = className.toLowerCase();
return (
(bedPosition === 'upper' && classNameLower.includes('upper')) ||
(bedPosition === 'middle' && classNameLower.includes('middle')) ||
(bedPosition === 'lower' && classNameLower.includes('lower'))
);
});
if (matchingClass) {
if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
availabilityByClass[matchingClass] += count;
}
}
}
} else {
let availableSeatsInCoach = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availableSeatsInCoach++;
}
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
availabilityByClass[seatClassName] += availableSeatsInCoach;
}
}
}
// Departure/arrival times for the requested leg (not the full schedule)
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
const faresByClass = await this.calculateFaresForSegment(
schedule,
dto.originStationId,
@@ -106,14 +139,14 @@ export class SearchService {
),
status: schedule.status,
stops: schedule.stopTimes
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map(st => ({
stationId: st.stationId,
stationName: st.station.name,
sequence: st.sequence,
plannedArrivalAt: st.plannedArrivalAt,
plannedDepartureAt: st.plannedDepartureAt,
})),
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map((st: any) => ({
stationId: st.stationId,
stationName: st.station.name,
sequence: st.sequence,
plannedArrivalAt: st.plannedArrivalAt,
plannedDepartureAt: st.plannedDepartureAt,
})),
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass,
@@ -134,51 +167,19 @@ export class SearchService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
throw new NotFoundException('Origin or destination not found on this schedule');
}
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
// Compute route codes for fare lookup
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const nationality = dto.nationality;
// Query fare rules with specificity ordering:
// 1. schedule+segment+nationality
// 2. schedule+segment
// 3. schedule+full-route+nationality
// 4. schedule+full-route
// 5. schedule+global
// 6. segment+nationality
// 7. segment
// 8. full-route+nationality
// 9. full-route
// 10. global
const fareRule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
orderBy: [
// Prioritize schedule-specific rules
{ tripId: { sort: 'desc', nulls: 'last' } },
// Then prioritize nationality match
{ nationality: { sort: 'desc', nulls: 'last' } },
// Most recent validFrom
{ validFrom: 'desc' },
],
});
// Manual specificity filtering to find best match
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: seatClass?.id,
@@ -243,38 +244,39 @@ export class SearchService {
};
}
/**
* Calculate fares for a specific segment of a schedule
*/
private async calculateFaresForSegment(
schedule: any,
originStationId: string,
destinationStationId: string,
nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
// Get seat classes that are actually assigned to this schedule via coaches
const assignedSeatClassIds: string[] = Array.from(
const seatClassIds: string[] = Array.from(
new Set(
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string)
schedule.coachAssignments
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
.map((sc: any) => sc.id)
.filter((id: any) => id)
)
);
// Get only the seat classes that are assigned to this schedule
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: assignedSeatClassIds }
},
orderBy: { basePrice: 'asc' },
});
// If no coaches assigned, return empty array
if (seatClasses.length === 0) {
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
}
// If schedule has a route, use route-based calculation
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: seatClassIds }
},
orderBy: { baseFareMinor: 'asc' },
});
if (seatClasses.length === 0) {
console.log(`No active seat classes for schedule ${schedule.id}`);
return [];
}
if (schedule.routeId) {
const results = await Promise.all(
seatClasses.map(async (sc) => {
@@ -303,7 +305,6 @@ export class SearchService {
}
}
// Fallback: Try to get fares from FareRule table
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
@@ -314,26 +315,25 @@ export class SearchService {
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
seatClassId: { in: assignedSeatClassIds },
seatClassId: { in: seatClassIds },
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
baseFareMinor: rule.baseFareMinor,
}));
}
}
// Last resort: Return default fares only for assigned seat classes
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({
seatClassName: sc.name,
@@ -359,60 +359,6 @@ export class SearchService {
return fares[seatClassName] ?? 45000;
}
/**
* Fallback method to get fares from FareRule table when fare engine fails
*/
private async getFallbackFares(
scheduleId: string,
originCode: string,
destCode: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
// Try to find fare rules for this segment
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
}));
}
// If no segment-specific rules, return default fares
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
return [
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
];
}
/**
* Select the best matching fare rule based on specificity:
* 1. schedule+segment+nationality
* 2. schedule+segment
* 3. schedule+full-route+nationality
* 4. schedule+full-route
* 5. schedule+global
* 6. segment+nationality
* 7. segment
* 8. full-route+nationality
* 9. full-route
* 10. global
*/
private selectBestFareRule(
candidates: any[],
scheduleId: string,
@@ -421,19 +367,16 @@ export class SearchService {
nationality?: string,
): any | null {
const priorities = [
// Schedule-specific rules
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
// Route-specific rules (no schedule)
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
// Global rules
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];

View File

@@ -1,41 +1,33 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
@Injectable()
export class SeatClassesService {
constructor(private prisma: PrismaService) {}
private readonly coachInclude = {
coaches: {
select: { id: true, coachNumber: true, label: true, mode: true, totalUnits: true, _count: { select: { seats: true } } },
orderBy: { label: 'asc' as const },
},
};
listSeatClasses() {
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude });
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
}
async getSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude });
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');
return sc;
}
async createSeatClass(dto: CreateSeatClassDto) {
async createSeatClass(dto: any) {
try {
return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude });
return await this.prisma.seatClass.create({ data: dto });
} catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
throw e;
}
}
async updateSeatClass(id: string, dto: UpdateSeatClassDto) {
async updateSeatClass(id: string, dto: any) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');
return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude });
return this.prisma.seatClass.update({ where: { id }, data: dto });
}
async deleteSeatClass(id: string) {

View File

@@ -1,8 +1,9 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SeatsService } from './seats.service';
import { HoldSeatsDto } from './seats.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Seats')
@Controller('seats')
@@ -78,6 +79,47 @@ This makes it clear which segment of the route each seat is held for, enabling s
@ApiResponse({ status: 404, description: 'Hold not found' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat blocked' })
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
return this.service.blockSeat(seatId, body.reason);
}
@Delete(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Unblock a seat' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat unblocked' })
unblockSeat(@Param('seatId') seatId: string) {
return this.service.unblockSeat(seatId);
}
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(':seatId/remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
@ApiResponse({ status: 404, description: 'Seat not found' })
removeSeat(@Param('seatId') seatId: string) {
return this.service.removeSeat(seatId);
}
@Patch(':seatId/undo-remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
@ApiResponse({ status: 404, description: 'Seat not found' })
@ApiResponse({ status: 400, description: 'Seat is not removed' })
undoRemoveSeat(@Param('seatId') seatId: string) {
return this.service.undoRemoveSeat(seatId);
}
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
async exportCSV(@Param('scheduleId') scheduleId: string) {
const csv = await this.service.exportSeatsCSV(scheduleId);

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
import { IamModule } from '../../common/iam.module';
@Module({
imports: [SegmentsModule],
imports: [SegmentsModule, HttpModule, IamModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],

View File

@@ -11,48 +11,68 @@ export class SeatsService {
private segmentsService: SegmentsService,
) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
async getSeatMap(scheduleId: string, coachId?: string) {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
include: {
coach: {
include: {
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
coachType: { include: { seatClasses: true } },
},
},
},
orderBy: { positionNumber: 'asc' },
});
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
return {
coaches: assignments.map((a) => ({
id: a.coach.id,
assignmentId: a.id,
name: `Coach ${a.coach.label}`,
seatClass: a.coach.seatClass.name,
positionNumber: a.positionNumber,
seats: a.coach.seats.map((s) => ({
id: s.id,
number: s.label,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
})),
})),
const response = {
coaches: assignments.map((a) => {
const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
return {
id: a.coach.id,
assignmentId: a.id,
coachNumber: a.coach.number,
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
seatClasses: seatClassNames,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
positionNumber: a.positionNumber,
seatArrangement: a.coach.arrangement,
totalSeats: a.coach.capacity,
seats: allSeats.map((s) => ({
id: s.id,
seatNumber: s.seatNumber,
number: s.seatNumber,
label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
coach: {
id: a.coach.id,
coachNumber: a.coach.number,
label: a.coach.number,
},
})),
};
}),
};
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
return response;
}
/**
* Computes the effective seat status for a set of seats on a specific schedule
* by checking active SeatHolds and confirmed JourneySegments.
*
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
*
* This is needed because seat.status is no longer written during booking —
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
*/
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
@@ -61,7 +81,6 @@ export class SeatsService {
if (seatIds.length === 0) return statusMap;
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
const activeHolds = await this.prisma.seatHold.findMany({
where: {
scheduleId,
@@ -76,8 +95,6 @@ export class SeatsService {
}
}
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
// (overwrites HELD if the same seat has a confirmed booking)
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
@@ -93,24 +110,21 @@ export class SeatsService {
return statusMap;
}
// ── Hold / Release ────────────────────────────────────────────────────────
async holdSeats(dto: HoldSeatsDto) {
// ── Validate request integrity ───────────────────────────────────────────
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
if (new Set(passengerIds).size !== passengerIds.length)
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
throw new BadRequestException('Duplicate passengerId in passengers list');
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
throw new BadRequestException('Duplicate seatId in passengers list');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },
select: { id: true, status: true, label: true },
select: { id: true, status: true, seatNumber: true },
});
if (seats.length !== seatIds.length) {
@@ -121,11 +135,10 @@ export class SeatsService {
const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
// ── 2. Resolve requested leg sequences ──────────────────────────────
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
@@ -137,17 +150,15 @@ export class SeatsService {
const reqTo = seqOf(dto.destinationStationId);
if (reqFrom === undefined || reqTo === undefined)
throw new BadRequestException('Origin or destination station not found on this schedule');
throw new BadRequestException('Origin or destination station not found');
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── 3. Load active holds for this schedule ───────────────────────────
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
// Parse each hold's leg range and passenger list
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
for (const h of activeHolds) {
try {
@@ -164,32 +175,28 @@ export class SeatsService {
});
}
}
} catch { /* ignore malformed */ }
} catch { /* ignore */ }
}
// ── 4. Per-passenger validation with overlap check ───────────────────
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
if (!legsOverlap) continue; // non-overlapping leg — no conflict
if (!legsOverlap) continue;
// Rule A: seat is held on an overlapping leg
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
`Seat ${seatLabelById[seatId]} is already held for this leg`,
);
}
// Rule B: passenger already holds a seat on an overlapping leg
if (hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
`Passenger already holds a seat on this journey leg`,
);
}
}
}
// Store passenger→seat mapping AND leg in createdBy as JSON
const holdMeta = {
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
@@ -228,12 +235,7 @@ export class SeatsService {
return this.enrichHold(hold);
}
/**
* Resolves the opaque fareQuoteId leg encoding into human-readable station
* names and enriches the hold with schedule, seat, and leg details.
*/
private async enrichHold(hold: any) {
// Decode leg and passenger→seat mapping from createdBy JSON
let originStationId: string | null = null;
let destinationStationId: string | null = null;
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
@@ -241,7 +243,6 @@ export class SeatsService {
try {
if (hold.createdBy) {
const raw = hold.createdBy;
// Guard: only parse if it looks like a JSON object, not a plain number/string
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
const meta = JSON.parse(raw);
originStationId = meta.originStationId ?? null;
@@ -249,7 +250,7 @@ export class SeatsService {
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
}
}
} catch { /* ignore malformed createdBy */ }
} catch { /* ignore */ }
const seatIds = hold.seatIds as string[];
@@ -262,7 +263,7 @@ export class SeatsService {
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
this.prisma.seat.findMany({
where: { id: { in: seatIds } },
include: { coach: { include: { seatClass: true } } },
include: { coach: true },
}),
]);
@@ -277,10 +278,8 @@ export class SeatsService {
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
}
// Build seat map keyed by seatId for quick lookup
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
// Merge passenger→seat mapping with seat details
const passengers = passengerSeatMap.length > 0
? passengerSeatMap.map(({ passengerId, seatId }) => {
const s = seatById[seatId];
@@ -288,26 +287,25 @@ export class SeatsService {
passengerId,
seat: s ? {
id: s.id,
label: s.label,
label: s.seatNumber,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
coach: s.coach.number,
seatClass: 'Standard',
row: s.row,
col: s.col,
} : { id: seatId },
};
})
// Fallback for holds created before this change
: seatIds.map(seatId => {
const s = seatById[seatId];
return {
passengerId: hold.passengerId,
seat: s ? {
id: s.id,
label: s.label,
label: s.seatNumber,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
coach: s.coach.number,
seatClass: 'Standard',
row: s.row,
col: s.col,
} : { id: seatId },
@@ -350,22 +348,26 @@ export class SeatsService {
}
async confirmSeats(seatIds: string[]) {
// No-op for status — availability is segment-scoped via JourneySegment
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
}
async releaseSeats(seatIds: string[]) {
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
// For segment-based bookings, releasing is handled by JourneySegment deletion
// No-op
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
async releaseSeats(seatIds: string[]) {
if (seatIds.length > 0) {
await this.prisma.journeySegment.deleteMany({
where: { seatId: { in: seatIds } },
});
}
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {
coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } },
coach: { assignments: { some: { scheduleId } } },
status: 'AVAILABLE',
...(eligibility ? { eligibility } : {}),
seatNumber: { not: '' },
NOT: { seatNumber: { startsWith: '-' } },
},
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
if (seats.length < count) {
@@ -400,10 +402,10 @@ export class SeatsService {
where: { scheduleId },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
});
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor'];
for (const a of assignments) {
for (const seat of a.coach.seats) {
rows.push(`${a.coach.id},${a.coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`);
rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`);
}
}
return rows.join('\n');
@@ -422,8 +424,8 @@ export class SeatsService {
invalid++;
continue;
}
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !label) {
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !seatNumber) {
errors.push(`Line ${i + 2}: Missing required fields`);
invalid++;
continue;
@@ -440,32 +442,30 @@ export class SeatsService {
let imported = 0;
if (!commit) {
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] };
return { imported: 0, errors: ['Preview mode'] };
}
for (let i = 0; i < lines.length; i++) {
try {
const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts;
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
update: {
label,
seatNumber,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
create: {
coachId,
row: parseInt(row),
col,
label,
seatNumber,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
});
imported++;
@@ -477,9 +477,86 @@ export class SeatsService {
return { imported, errors: errors.slice(0, 10) };
}
async blockSeat(seatId: string, reason: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BLOCKED' },
});
await this.prisma.seatBlock.create({
data: {
seatId,
reason,
blockedBy: 'system',
},
});
return { blocked: true, seatId, reason };
}
async unblockSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'AVAILABLE' },
});
await this.prisma.seatBlock.deleteMany({
where: { seatId },
});
return { unblocked: true, seatId };
}
async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
const negatedNumber = `-${seat.seatNumber}`;
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: negatedNumber },
});
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}
async undoRemoveSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) {
throw new BadRequestException('Seat is not removed');
}
// Restore original seatNumber by removing the negative sign
const originalNumber = seat.seatNumber.slice(1);
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: originalNumber },
});
return { restored: true, seatId, seatNumber: originalNumber };
}
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); }
for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
try {
await this.prisma.seatHold.delete({ where: { id: hold.id } });
} catch (err) {
if (err instanceof Error && !err.message.includes('P2025')) {
throw err;
}
}
}
}
}

View File

@@ -87,7 +87,7 @@ async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passe
for (const seat of seats) {
if (seat.status !== 'AVAILABLE') {
throw new Error(`Seat ${seat.label} is not available (status: ${seat.status})`);
throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
}
}

View File

@@ -35,14 +35,12 @@ export class EnhancedSeatsService {
for (const seatId of request.seatIds) {
const seat = await tx.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
// segment does not overlap (another passenger may occupy a different leg)
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`);
const free = await this.segmentsService.isSeatFreeForLeg(
request.scheduleId, seatId, reqFrom, reqTo,
);
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`);
if (!free) throw new ConflictException(`Seat ${seat.seatNumber} is not available for the requested leg`);
}
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
@@ -51,7 +49,6 @@ export class EnhancedSeatsService {
scheduleId: request.scheduleId,
seatIds: request.seatIds,
passengerId: request.passengerId,
// Store leg in createdBy JSON — no fareQuoteId needed
createdBy: JSON.stringify({
originStationId: request.originStationId,
destinationStationId: request.destinationStationId,
@@ -60,7 +57,6 @@ export class EnhancedSeatsService {
},
});
// Do NOT set seat.status = HELD globally — status is segment-scoped
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
});
@@ -81,7 +77,6 @@ export class EnhancedSeatsService {
});
if (!schedule) throw new BadRequestException('Schedule not found');
// Resolve the passenger's leg from createdBy JSON
let originStationId: string | undefined;
let destinationStationId: string | undefined;
try {
@@ -92,15 +87,15 @@ export class EnhancedSeatsService {
}
} catch { /* ignore */ }
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
const originStop = originStationId ? schedule.stopTimes.find((s: any) => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined;
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
const segments: Segment[] = [];
for (let i = fromSeq; i < toSeq; i++) {
const fromStop = schedule.stopTimes.find(s => s.sequence === i);
const toStop = schedule.stopTimes.find(s => s.sequence === i + 1);
const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i);
const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
@@ -132,7 +127,6 @@ export class EnhancedSeatsService {
}
}
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
await tx.seatHold.delete({ where: { id: request.holdId } });
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
@@ -185,22 +179,20 @@ export class EnhancedSeatsService {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } },
include: { coachAssignments: { include: { coach: { include: { seats: true } } } } },
});
if (!schedule) throw new BadRequestException('Schedule not found');
const availableSeats = [];
for (const assignment of schedule.coachAssignments) {
for (const seat of assignment.coach.seats) {
// Hard-blocked seats are never available
if (seat.status === 'BLOCKED') continue;
// Availability is determined purely by segment overlap — not global seat.status
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
if (free) {
availableSeats.push({
id: seat.id, label: seat.label,
coach: assignment.coach.label,
seatClass: assignment.coach.seatClass.name,
id: seat.id, label: seat.seatNumber,
coach: assignment.coach.number,
seatClass: 'Standard',
row: seat.row, col: seat.col,
kind: seat.kind,
isWindow: seat.isWindow,

View File

@@ -1,16 +1,34 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Tickets')
@Controller('tickets')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class TicketsController {
constructor(private service: TicketsService) {}
@Post('generate/:bookingId')
@ApiOperation({
summary: 'Generate ticket for booking (confirmation page)',
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
})
generateTicket(@Param('bookingId') bookingId: string) {
return this.service.generate(bookingId);
}
@Patch('update-seats/:bookingId')
@ApiOperation({
summary: 'Update ticket seats before final confirmation',
description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.'
})
updateSeats(@Param('bookingId') bookingId: string, @Body() body: { seatIds: string[] }) {
return this.service.updateSeats(bookingId, body.seatIds);
}
@Get()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
listTickets(
@Query('search') search?: string,
@@ -27,6 +45,8 @@ export class TicketsController {
}
@Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get ticket with QR code and passenger details',
description: `Returns ticket information including:
@@ -42,6 +62,8 @@ export class TicketsController {
}
@Post(':bookingRef/validate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
@@ -55,27 +77,35 @@ export class TicketsController {
}
@Get(':ticketId/validation-logs')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) {
return this.service.getValidationLogs(ticketId);
}
@Get('offline/export')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('scheduleId') scheduleId: string) {
return this.service.exportOfflineData(scheduleId);
}
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' })
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Delete ticket (admin only)',
description: 'Permanently deletes a ticket record'
description: 'Permanently deletes a ticket record and removes associated seat blocks'
})
delete(@Param('id') id: string) {
return this.service.delete(id);

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] })
@Module({
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],
})
export class TicketsModule {}
export { TicketsController } from './tickets.controller';

View File

@@ -19,6 +19,7 @@ export class TicketsService {
where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.status) {
@@ -44,7 +45,13 @@ export class TicketsService {
items: tickets.map((t) => ({
id: t.id,
ticketNumber: t.barcodePayload,
booking: t.booking,
bookingRef: t.bookingRef,
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
contactEmail: t.booking.contactEmail,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.booking.status,
@@ -58,18 +65,96 @@ export class TicketsService {
}
async generate(bookingId: string) {
if (!bookingId) {
throw new BadRequestException('Booking ID is required');
}
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
return this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }
const ticket = await this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
// Update all booked seats from HELD to BOOKED and create permanent seat blocks
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
// Update seat status to BOOKED
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BOOKED' },
});
// Create permanent seat blocks for all booked seats
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null); // Ignore if already exists
}
return ticket;
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, ticket: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
// Remove old seat blocks
const oldSeatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of oldSeatIds) {
await this.prisma.seatBlock.deleteMany({
where: {
seatId,
reason: { contains: booking.ticket.id }
}
});
}
// Remove old booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat blocks
for (const seatId of newSeatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${booking.ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null);
}
// Create new booking seats (placeholder with minimal data)
for (let i = 0; i < newSeatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: newSeatIds[i],
passengerName: `Passenger ${i + 1}`,
}
});
}
return { success: true, updatedSeats: newSeatIds.length };
}
async getByRef(bookingRef: string) {
@@ -83,7 +168,7 @@ export class TicketsService {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
@@ -128,8 +213,8 @@ export class TicketsService {
bookingRef: b.bookingRef,
ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.label,
coachLabel: b.seats[0]?.seat.coach.label,
seatLabel: b.seats[0]?.seat.seatNumber,
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
validatedAt: b.ticket?.validatedAt,
@@ -197,6 +282,14 @@ export class TicketsService {
if (!ticket) throw new NotFoundException('Ticket not found');
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } });
// Remove seat blocks associated with this ticket
await this.prisma.seatBlock.deleteMany({
where: {
reason: { contains: id }
}
});
await this.prisma.ticket.delete({ where: { id } });
return { deleted: true, ticketId: id };

View File

@@ -1,13 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
},
images: {
unoptimized: true, // Required for static export
unoptimized: true,
},
};

View File

@@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev -p 5184",
"build": "next build",
"start": "next start -p 5184",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},

View File

@@ -0,0 +1,331 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { seatClassesApi, apiClient } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
export default function ClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['classes', filters],
queryFn: () => seatClassesApi.getAll(),
});
const { data: coachTypes } = useQuery<any>({
queryKey: ['coach-types'],
queryFn: () => apiClient.get('/fleet/coach-types'),
});
useEffect(() => {
if (showModal && editingClass) {
setSelectedCoachTypeId(editingClass.coachTypeId || '');
}
}, [showModal, editingClass]);
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
},
});
const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!selectedCoachTypeId) {
alert('Please select a coach type');
return;
}
const formData = new FormData(e.currentTarget);
const classData = {
coachTypeId: selectedCoachTypeId,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
isActive: formData.get('isActive') === 'true',
};
if (editingClass) {
await updateMutation.mutateAsync({ id: editingClass.id, data: classData });
} else {
await createMutation.mutateAsync(classData);
}
};
const handleDelete = (cls: any) => {
setDeleteConfirm({ isOpen: true, class: cls });
};
const confirmDelete = async () => {
if (deleteConfirm.class) {
await deleteMutation.mutateAsync(deleteConfirm.class.id);
setDeleteConfirm({ isOpen: false, class: null });
}
};
const coachTypesArray = Array.isArray(coachTypes) ? coachTypes : ((coachTypes as any)?.data || (coachTypes as any)?.items || []);
const coachTypeMap = coachTypesArray.reduce((map: any, ct: any) => {
map[ct.id] = ct.name;
return map;
}, {});
const filteredClasses = (data as any)?.items || (Array.isArray(data) ? data : []);
const displayedClasses = filteredClasses.filter((cls: any) => {
if (!filters.search) return true;
const searchLower = filters.search.toLowerCase();
return (
cls.name?.toLowerCase().includes(searchLower) ||
cls.coachType?.name?.toLowerCase().includes(searchLower) ||
cls.description?.toLowerCase().includes(searchLower)
);
});
const columns = [
{
key: 'coachType',
label: 'Coach Type',
render: (cls: any) => (
<span className="text-sm">{cls.coachType?.name || coachTypeMap[cls.coachTypeId] || 'N/A'}</span>
),
},
{
key: 'name',
label: 'Class Name',
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
},
{
key: 'description',
label: 'Description',
render: (cls: any) => (
<span className="text-sm text-muted-foreground">{cls.description || '-'}</span>
),
},
{
key: 'baseFareMinor',
label: 'Base Fare (ETB)',
render: (cls: any) => (
<span className="font-mono text-sm">{formatCurrency(cls.baseFareMinor, 'ETB')}</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (cls: any) => (
<Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{cls.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const handleOpenModal = (cls?: any) => {
if (cls) {
setEditingClass(cls);
} else {
setEditingClass(null);
}
setShowModal(true);
};
const actions = [
{
label: 'Edit',
onClick: (cls: any) => handleOpenModal(cls),
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage class configurations by coach type</p>
</div>
<ActionButton
icon={Plus}
onClick={() => handleOpenModal()}
>
Add Class
</ActionButton>
</div>
<div className="card">
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, coach type, or description..."
className="input pl-10 w-full"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
</div>
<DataTable
data={displayedClasses}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={filters.search ? "No classes match your search" : "No classes found"}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, class: null })}
onConfirm={confirmDelete}
title="Delete Class"
message={`Are you sure you want to delete ${deleteConfirm.class?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
}}
title={`${editingClass ? 'Edit' : 'Add'} Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Coach Type *</label>
<select
name="coachTypeId"
className="input"
value={selectedCoachTypeId}
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
required
>
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
</option>
))}
</select>
</div>
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingClass?.name || ''}
required
placeholder="e.g., Economy Regular"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingClass?.description || ''}
placeholder="Describe this class..."
/>
</div>
<div>
<label className="label">Base Fare (ETB cents) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor || ''}
required
min="0"
placeholder="e.g., 45000 (450 ETB)"
/>
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingClass?.isActive !== undefined ? editingClass.isActive.toString() : 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingClass(null);
setSelectedCoachTypeId('');
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingClass ? 'Update' : 'Create'} Class
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -2,141 +2,251 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fleetApi } from '@/lib/api';
import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
import { fleetApi, apiClient } from '@/lib/api';
type Tab = 'types' | 'coaches';
export default function CoachesPage() {
const [activeTab, setActiveTab] = useState<Tab>('coaches');
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingCoach, setEditingCoach] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['coaches', search],
queryFn: () => fleetApi.getCoaches({ search }),
// Coach Types Queries
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
queryKey: ['coach-types'],
queryFn: () => apiClient.get('/fleet/coach-types'),
});
const createMutation = useMutation({
// Coaches Queries
const { data: coachesData, isLoading: coachesLoading } = useQuery({
queryKey: ['coaches'],
queryFn: () => fleetApi.getCoaches({}),
});
// Coach Type Mutations
const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
setShowModal(false);
setEditingItem(null);
},
});
const updateCoachTypeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/fleet/coach-types/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
setShowModal(false);
setEditingItem(null);
},
});
const deleteCoachTypeMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/fleet/coach-types/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
},
});
// Coach Mutations
const createCoachMutation = useMutation({
mutationFn: fleetApi.createCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
setEditingItem(null);
},
});
const updateMutation = useMutation({
const updateCoachMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
setEditingItem(null);
},
});
const deleteMutation = useMutation({
const deleteCoachMutation = useMutation({
mutationFn: fleetApi.deleteCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const coachData = {
coachNumber: formData.get('coachNumber') as string,
label: formData.get('label') as string,
seatClassId: formData.get('seatClassId') as string,
coachType: formData.get('coachType') as string,
mode: formData.get('mode') as string,
seatArrangement: formData.get('seatArrangement') as string,
totalUnits: parseInt(formData.get('totalUnits') as string),
isActive: formData.get('isActive') === 'true',
const data = {
code: formData.get('code') as string,
name: formData.get('name') as string,
type: formData.get('type') as string,
};
if (editingCoach) {
await updateMutation.mutateAsync({ id: editingCoach.id, data: coachData });
if (editingItem?.isCoachType) {
await updateCoachTypeMutation.mutateAsync({ id: editingItem.id, data });
} else {
await createMutation.mutateAsync(coachData);
await createCoachTypeMutation.mutateAsync(data);
}
};
const handleDelete = (coach: any) => {
setDeleteConfirm({ isOpen: true, coach });
const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
number: formData.get('number') as string,
coachTypeId: formData.get('coachTypeId') as string,
arrangement: formData.get('arrangement') as string,
capacity: parseInt(formData.get('capacity') as string),
status: formData.get('status') as string,
};
if (editingItem?.isCoach) {
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
} else {
await createCoachMutation.mutateAsync(data);
}
};
const handleDelete = (item: any, isCoachType: boolean) => {
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
};
const confirmDelete = async () => {
if (deleteConfirm.coach) {
await deleteMutation.mutateAsync(deleteConfirm.coach.id);
setDeleteConfirm({ isOpen: false, coach: null });
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
};
const coaches = data?.items || data?.data || [];
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
const coaches = coachesData?.items || coachesData?.data || [];
const columns = [
const filteredCoachTypes = coachTypesArray.filter((ct: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
ct.code?.toLowerCase().includes(searchLower) ||
ct.name?.toLowerCase().includes(searchLower) ||
ct.type?.toLowerCase().includes(searchLower)
);
});
const filteredCoaches = coaches.filter((coach: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
coach.number?.toLowerCase().includes(searchLower) ||
coach.coachNumber?.toLowerCase().includes(searchLower) ||
coach.coachType?.name?.toLowerCase().includes(searchLower) ||
coach.arrangement?.toLowerCase().includes(searchLower)
);
});
const typeColorMap: Record<string, string> = {
passenger: 'edr-badge-info',
sleeper: 'edr-badge-warning',
dining: 'edr-badge-success',
baggage: 'edr-badge-danger',
};
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
// Coach Types Columns
const coachTypeColumns = [
{
key: 'coachNumber',
label: 'Coach Number',
key: 'code',
label: 'Code',
sortable: true,
render: (ct: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" />
</div>
<span className="font-mono font-medium">{ct.code}</span>
</div>
),
},
{
key: 'name',
label: 'Name',
sortable: true,
render: (ct: any) => <span className="font-medium">{ct.name}</span>,
},
{
key: 'type',
label: 'Type',
render: (ct: any) => (
<span className={`edr-badge ${typeColorMap[ct.type] || 'edr-badge-info'}`}>
{ct.type}
</span>
),
},
{
key: 'coaches',
label: 'Coaches',
render: (ct: any) => (
<span className="text-sm font-medium">{ct.coaches?.length || 0}</span>
),
},
];
// Coaches Columns
const coachColumns = [
{
key: 'number',
label: 'Number',
sortable: true,
render: (coach: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" />
</div>
<span className="font-medium">{coach.coachNumber}</span>
<span className="font-medium">{coach.number || coach.coachNumber}</span>
</div>
),
},
{
key: 'seatClass',
label: 'Seat Class',
render: (coach: any) => {
const seatClass = coach.seatClass?.name || coach.serviceClass || 'N/A';
const colorMap: Record<string, string> = {
'ECONOMY_REGULAR': 'edr-badge-info',
'ECONOMY_BED': 'edr-badge-warning',
'VIP_BED': 'edr-badge-success',
};
return (
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
{seatClass.replace(/_/g, ' ')}
</span>
);
},
},
{
key: 'totalSeats',
label: 'Total Seats',
key: 'coachType',
label: 'Coach Type',
render: (coach: any) => (
<span className="font-mono text-sm">{coach.totalSeats || coach.totalUnits || 0}</span>
<span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
),
},
{
key: 'layout',
label: 'Layout',
key: 'arrangement',
label: 'Arrangement',
render: (coach: any) => (
<span className="text-sm text-muted-foreground">
{coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'}
</span>
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
),
},
{
key: 'capacity',
label: 'Capacity',
render: (coach: any) => (
<span className="font-mono text-sm font-medium">{coach.capacity || 0}</span>
),
},
{
key: 'status',
label: 'Status',
render: (coach: any) => {
const status = coach.isActive ? 'ACTIVE' : 'INACTIVE';
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
const status = coach.status || 'ACTIVE';
return (
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
{status}
@@ -146,11 +256,11 @@ export default function CoachesPage() {
},
];
const actions = [
const coachTypeActions = [
{
label: 'Edit',
onClick: (coach: any) => {
setEditingCoach(coach);
onClick: (item: any) => {
setEditingItem({ ...item, isCoachType: true });
setShowModal(true);
},
variant: 'secondary' as const,
@@ -158,7 +268,25 @@ export default function CoachesPage() {
},
{
label: 'Delete',
onClick: handleDelete,
onClick: (item: any) => handleDelete(item, true),
variant: 'danger' as const,
icon: Trash2,
},
];
const coachActions = [
{
label: 'Edit',
onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true });
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (item: any) => handleDelete(item, false),
variant: 'danger' as const,
icon: Trash2,
},
@@ -169,51 +297,112 @@ export default function CoachesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Coach Management</h1>
<p className="text-muted-foreground mt-1">Manage train coaches and configurations</p>
<p className="text-muted-foreground mt-1">Manage coach types and train coaches</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingCoach(null);
setEditingItem(null);
setSearch('');
setShowModal(true);
}}
>
Add Coach
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
</ActionButton>
</div>
{/* Tabs */}
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search coaches..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
<div className="flex border-b border-border">
<button
onClick={() => {
setActiveTab('types');
setSearch('');
}}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'types'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Coach Types
</button>
<button
onClick={() => {
setActiveTab('coaches');
setSearch('');
}}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'coaches'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Coaches
</button>
</div>
<DataTable
columns={columns}
data={coaches}
actions={actions}
loading={isLoading}
/>
{/* Coach Types Tab */}
{activeTab === 'types' && (
<div className="pt-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or type..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
columns={coachTypeColumns}
data={filteredCoachTypes}
actions={coachTypeActions}
loading={typesLoading}
emptyMessage={search ? "No coach types match your search" : "No coach types found. Create one to get started."}
/>
</div>
)}
{/* Coaches Tab */}
{activeTab === 'coaches' && (
<div className="pt-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by coach number, type, or arrangement..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
columns={coachColumns}
data={filteredCoaches}
actions={coachActions}
loading={coachesLoading}
emptyMessage={search ? "No coaches match your search" : "No coaches found. Create one to get started."}
/>
</div>
)}
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, coach: null })}
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
onConfirm={confirmDelete}
title="Delete Coach"
message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`}
title={`Delete ${deleteConfirm.item?.isCoachType ? 'Coach Type' : 'Coach'}`}
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete"
isDanger={true}
warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems."
warning={
deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
: 'This coach may be assigned to schedules. Deleting it may impact these systems.'
}
/>
{/* Add/Edit Modal */}
@@ -221,106 +410,170 @@ export default function CoachesPage() {
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingCoach(null);
setEditingItem(null);
}}
title={`${editingCoach ? 'Edit' : 'Add'} Coach`}
size="lg"
title={
activeTab === 'types'
? `${editingItem?.isCoachType ? 'Edit' : 'Add'} Coach Type`
: `${editingItem?.isCoach ? 'Edit' : 'Add'} Coach`
}
size={activeTab === 'types' ? 'md' : 'lg'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Number *</label>
<input
type="text"
name="coachNumber"
className="input"
defaultValue={editingCoach?.coachNumber}
required
placeholder="e.g., C001"
/>
{/* Coach Type Form */}
{activeTab === 'types' && (
<form onSubmit={handleCoachTypeSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Code</label>
<input
type="text"
name="code"
className="input"
defaultValue={editingItem?.code || ''}
required
placeholder="e.g., HSC"
/>
</div>
<div>
<label className="label">Name</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingItem?.name || ''}
required
placeholder="e.g., Hard Seat Coach"
/>
</div>
<div>
<label className="label">Type</label>
<input
type="text"
name="type"
className="input"
defaultValue={editingItem?.type || ''}
required
placeholder="e.g., Regular Seat"
/>
</div>
</div>
<div>
<label className="label">Label *</label>
<input
type="text"
name="label"
className="input"
defaultValue={editingCoach?.label}
required
placeholder="e.g., Coach 1"
/>
</div>
<div>
<label className="label">Coach Type</label>
<select name="coachType" className="input" defaultValue={editingCoach?.coachType}>
<option value="passenger">Passenger</option>
<option value="sleeper">Sleeper</option>
<option value="dining">Dining</option>
<option value="baggage">Baggage</option>
</select>
</div>
<div>
<label className="label">Mode *</label>
<select name="mode" className="input" defaultValue={editingCoach?.mode || 'seat'}>
<option value="seat">Seat</option>
<option value="bed">Bed</option>
<option value="convertible">Convertible</option>
</select>
</div>
<div>
<label className="label">Seat Arrangement</label>
<input
type="text"
name="seatArrangement"
className="input"
defaultValue={editingCoach?.seatArrangement}
placeholder="e.g., 2+2"
/>
</div>
<div>
<label className="label">Total Units *</label>
<input
type="number"
name="totalUnits"
className="input"
defaultValue={editingCoach?.totalUnits}
required
min="1"
placeholder="60"
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingCoach?.isActive?.toString() || 'true'}
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingItem(null);
}}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createCoachTypeMutation.isPending || updateCoachTypeMutation.isPending}
>
{editingItem?.isCoachType ? 'Update' : 'Create'} Coach Type
</ActionButton>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingCoach(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
</form>
)}
{/* Coach Form */}
{activeTab === 'coaches' && (
<form onSubmit={handleCoachSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Type</label>
<select
name="coachTypeId"
className="input"
defaultValue={editingItem?.coachTypeId || ''}
required
>
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name} - {ct.type}
</option>
))}
</select>
</div>
<div>
<label className="label">Number</label>
<input
type="text"
name="number"
className="input"
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
required
placeholder="e.g., HSC-0001"
/>
</div>
<div>
<label className="label">Arrangement</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
required
placeholder="e.g., 2+2, 3+2"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
</div>
<div>
<label className="label">Capacity</label>
<input
type="number"
name="capacity"
className="input"
defaultValue={editingItem?.capacity || editingItem?.totalUnits || ''}
required
min="1"
placeholder="60"
/>
</div>
<div className="md:col-span-2">
<label className="label">Status</label>
<select
name="status"
className="input"
defaultValue={editingItem?.status || 'ACTIVE'}
>
<option value="ACTIVE">Active</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingItem(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createCoachMutation.isPending || updateCoachMutation.isPending}
>
{editingItem?.isCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
)}
</Modal>
</div>
);

View File

@@ -6,8 +6,8 @@ import { useAuthStore } from '@/lib/auth-store';
import { Train } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('admin@edr-platform.com');
const [password, setPassword] = useState('admin123');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();

View File

@@ -18,6 +18,7 @@ export default function PassengersPage() {
page: 1,
pageSize: 20,
search: '',
role: 'PASSENGER',
});
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });

View File

@@ -1,5 +1,54 @@
import DashboardLayout from '../dashboard/layout';
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function PricingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
'use client';
import DashboardLayout from '../dashboard/layout';
export default function PromosLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,409 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Copy, Check } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { promosApi, PromoCode } from '@/lib/api/promos';
export default function PromosPage() {
const [filters, setFilters] = useState({ search: '', active: '', page: 1, pageSize: 10 });
const [showModal, setShowModal] = useState(false);
const [editingPromo, setEditingPromo] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; promo: any | null }>({ isOpen: false, promo: null });
const [copiedCode, setCopiedCode] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['promos', filters],
queryFn: () => promosApi.getAll(filters),
});
const createMutation = useMutation({
mutationFn: promosApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promos'] });
setShowModal(false);
setEditingPromo(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => promosApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promos'] });
setShowModal(false);
setEditingPromo(null);
},
});
const deleteMutation = useMutation({
mutationFn: promosApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promos'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const promoData = {
code: formData.get('code') as string,
title: formData.get('title') as string,
discountType: formData.get('discountType') as 'PERCENTAGE' | 'FIXED',
discountValue: parseFloat(formData.get('discountValue') as string),
maxDiscount: formData.get('maxDiscount') ? parseFloat(formData.get('maxDiscount') as string) : undefined,
minBookingAmount: formData.get('minBookingAmount') ? parseFloat(formData.get('minBookingAmount') as string) : undefined,
maxUsagePerUser: formData.get('maxUsagePerUser') ? parseInt(formData.get('maxUsagePerUser') as string) : undefined,
totalUsageLimit: formData.get('totalUsageLimit') ? parseInt(formData.get('totalUsageLimit') as string) : undefined,
validFrom: formData.get('validFrom') as string,
validUntil: formData.get('validUntil') as string,
isActive: formData.get('isActive') === 'true',
};
if (editingPromo) {
await updateMutation.mutateAsync({ id: editingPromo.id, data: promoData });
} else {
await createMutation.mutateAsync(promoData);
}
};
const handleDelete = (promo: any) => {
setDeleteConfirm({ isOpen: true, promo });
};
const confirmDelete = async () => {
if (deleteConfirm.promo) {
await deleteMutation.mutateAsync(deleteConfirm.promo.id);
setDeleteConfirm({ isOpen: false, promo: null });
}
};
const copyToClipboard = (code: string) => {
navigator.clipboard.writeText(code);
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 2000);
};
const columns = [
{
key: 'code',
label: 'Promo Code',
render: (promo: PromoCode) => (
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-lg">{promo.code}</span>
<button
onClick={() => copyToClipboard(promo.code)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title="Copy code"
>
{copiedCode === promo.code ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
),
},
{
key: 'title',
label: 'Title',
render: (promo: PromoCode) => (
<span className="text-sm font-medium">{promo.title || '-'}</span>
),
},
{
key: 'discount',
label: 'Discount',
render: (promo: PromoCode) => (
<span className="font-semibold">
{promo.discountType === 'PERCENTAGE'
? `${promo.discountValue}%`
: `ETB ${promo.discountValue}`}
</span>
),
},
{
key: 'validity',
label: 'Valid Period',
render: (promo: PromoCode) => (
<div className="text-sm">
<div>{new Date(promo.validFrom).toLocaleDateString()}</div>
<div className="text-muted-foreground">{new Date(promo.validUntil).toLocaleDateString()}</div>
</div>
),
},
{
key: 'usage',
label: 'Usage',
render: (promo: PromoCode) => (
<div className="text-sm">
<div>{promo.usageCount} used</div>
{promo.totalUsageLimit && (
<div className="text-muted-foreground">/ {promo.totalUsageLimit} limit</div>
)}
</div>
),
},
{
key: 'status',
label: 'Status',
render: (promo: PromoCode) => (
<Badge variant="status" status={promo.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{promo.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const actions = [
{
label: 'Edit',
onClick: (promo: PromoCode) => {
setEditingPromo(promo);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Promo Codes</h1>
<p className="text-muted-foreground">Manage promotional codes and discounts</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingPromo(null);
setShowModal(true);
}}
>
Add Promo Code
</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<input
type="text"
placeholder="Search promo codes..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div>
<div>
<select
className="input"
value={filters.active}
onChange={(e) => setFilters({ ...filters, active: e.target.value, page: 1 })}
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
</div>
{/* Promos Table */}
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No promo codes found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, promo: null })}
onConfirm={confirmDelete}
title="Delete Promo Code"
message={`Are you sure you want to delete promo code "${deleteConfirm.promo?.code}"?`}
confirmText="Delete"
isDanger={true}
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingPromo(null);
}}
title={`${editingPromo ? 'Edit' : 'Create'} Promo Code`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Promo Code *</label>
<input
type="text"
name="code"
className="input uppercase"
defaultValue={editingPromo?.code}
required
placeholder="e.g., SUMMER2024"
maxLength={20}
disabled={!!editingPromo}
/>
</div>
<div>
<label className="label">Title *</label>
<input
type="text"
name="title"
className="input"
defaultValue={editingPromo?.title}
required
placeholder="e.g., Summer Discount"
/>
</div>
<div>
<label className="label">Discount Type *</label>
<select
name="discountType"
className="input"
defaultValue={editingPromo?.discountType || 'PERCENTAGE'}
required
>
<option value="PERCENTAGE">Percentage (%)</option>
<option value="FIXED">Fixed Amount (ETB)</option>
</select>
</div>
<div>
<label className="label">Discount Value *</label>
<input
type="number"
name="discountValue"
className="input"
defaultValue={editingPromo?.discountValue}
required
placeholder="e.g., 15"
min="0"
step="0.01"
/>
</div>
<div>
<label className="label">Max Discount (ETB)</label>
<input
type="number"
name="maxDiscount"
className="input"
defaultValue={editingPromo?.maxDiscount}
placeholder="e.g., 500"
min="0"
step="0.01"
/>
</div>
<div>
<label className="label">Min Booking Amount (ETB)</label>
<input
type="number"
name="minBookingAmount"
className="input"
defaultValue={editingPromo?.minBookingAmount}
placeholder="e.g., 1000"
min="0"
step="0.01"
/>
</div>
<div>
<label className="label">Max Usage Per User</label>
<input
type="number"
name="maxUsagePerUser"
className="input"
defaultValue={editingPromo?.maxUsagePerUser}
placeholder="Unlimited if empty"
min="1"
/>
</div>
<div>
<label className="label">Total Usage Limit</label>
<input
type="number"
name="totalUsageLimit"
className="input"
defaultValue={editingPromo?.totalUsageLimit}
placeholder="Unlimited if empty"
min="1"
/>
</div>
<div>
<label className="label">Valid From *</label>
<input
type="datetime-local"
name="validFrom"
className="input"
defaultValue={editingPromo?.validFrom?.slice(0, 16)}
required
/>
</div>
<div>
<label className="label">Valid Until *</label>
<input
type="datetime-local"
name="validUntil"
className="input"
defaultValue={editingPromo?.validUntil?.slice(0, 16)}
required
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingPromo?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingPromo(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingPromo ? 'Update' : 'Create'} Promo Code
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, X } from 'lucide-react';
import { Plus, Edit, Trash2, X, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -26,6 +26,7 @@ export default function RoutesPage() {
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({
@@ -81,10 +82,8 @@ export default function RoutesPage() {
return;
}
// Sort middle stops by distance from origin
const sortedMiddleStops = [...stops].sort((a, b) =>
(a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0)
);
// Keep current stop order (already rearranged by user)
const sortedMiddleStops = stops;
// Calculate distanceKm (distance from previous stop)
const stopsArray = [
@@ -136,6 +135,30 @@ export default function RoutesPage() {
setStops(updated);
};
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.setData('text/plain', index.toString());
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.opacity = '0.5';
};
const handleDragLeave = (e: React.DragEvent) => {
(e.currentTarget as HTMLElement).style.opacity = '1';
};
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.opacity = '1';
const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'));
if (sourceIndex === targetIndex) return;
const newStops = [...stops];
const [draggedStop] = newStops.splice(sourceIndex, 1);
newStops.splice(targetIndex, 0, draggedStop);
setStops(newStops);
};
const generateRouteCode = (originId: string, destId: string) => {
if (!originId || !destId) return '';
const origin = stations?.items?.find((s: any) => s.id === originId);
@@ -176,6 +199,17 @@ export default function RoutesPage() {
},
];
const filteredRoutes = (routes as any)?.items || (Array.isArray(routes) ? routes : []);
const displayedRoutes = filteredRoutes.filter((route: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
route.code?.toLowerCase().includes(searchLower) ||
route.name?.toLowerCase().includes(searchLower) ||
route.description?.toLowerCase().includes(searchLower)
);
});
const routeActions = [
{
label: 'Edit',
@@ -238,6 +272,7 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
setShowModal(true);
}}
>
@@ -245,15 +280,25 @@ export default function RoutesPage() {
</ActionButton>
</div>
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or description..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
data={(routes as any)?.items || (Array.isArray(routes) ? routes : [])}
data={displayedRoutes}
columns={routeColumns}
actions={routeActions}
loading={routesLoading}
emptyMessage="No routes found"
emptyMessage={search ? "No routes match your search" : "No routes found"}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, route: null })}
@@ -265,7 +310,6 @@ export default function RoutesPage() {
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
@@ -275,6 +319,7 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
}}
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg"
@@ -358,7 +403,7 @@ export default function RoutesPage() {
className="input"
rows={2}
defaultValue={editingRoute?.description}
placeholder="Main corridor via Dire Dawa"
placeholder="Outbound local route from [Origin] to [Destination]"
/>
</div>
@@ -387,10 +432,10 @@ export default function RoutesPage() {
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
</div>
<div className="space-y-2">
{/* Origin Stop */}
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
1
@@ -410,9 +455,16 @@ export default function RoutesPage() {
</div>
</div>
{/* Intermediate Stops */}
{stops.map((stop, index) => (
<div key={index} className="flex gap-2 items-center p-3 bg-muted/50 rounded">
<div
key={index}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
>
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{index + 2}
</div>
@@ -457,7 +509,6 @@ export default function RoutesPage() {
</div>
))}
{/* Add Intermediate Stop Button */}
{originStationId && destinationStationId && (
<div className="flex justify-center py-2">
<ActionButton
@@ -472,7 +523,6 @@ export default function RoutesPage() {
</div>
)}
{/* Destination Stop */}
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{stops.length + 2}
@@ -516,6 +566,7 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
}}
>
Cancel

View File

@@ -1,5 +1,54 @@
import DashboardLayout from '../dashboard/layout';
'use client';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function SchedulesLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,236 +0,0 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Plus, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function SeatClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingSeatClass, setEditingSeatClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['seat-classes', filters],
queryFn: () => seatClassesApi.getAll(),
});
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const seatClassData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
basePrice: Math.round(parseFloat(formData.get('basePrice') as string) * 100), // Convert to minor units
isActive: formData.get('isActive') === 'true',
};
if (editingSeatClass) {
await updateMutation.mutateAsync({ id: editingSeatClass.id, data: seatClassData });
} else {
await createMutation.mutateAsync(seatClassData);
}
};
const handleDelete = (seatClass: any) => {
setDeleteConfirm({ isOpen: true, seatClass });
};
const confirmDelete = async () => {
if (deleteConfirm.seatClass) {
await deleteMutation.mutateAsync(deleteConfirm.seatClass.id);
setDeleteConfirm({ isOpen: false, seatClass: null });
}
};
const columns = [
{ key: 'name', label: 'Name', render: (cls: any) => <span className="font-medium">{cls.name}</span> },
{ key: 'description', label: 'Description', render: (cls: any) => cls.description || 'N/A' },
{ key: 'basePrice', label: 'Base Price', render: (cls: any) => formatCurrency(cls.basePrice, 'ETB') },
{ key: 'isActive', label: 'Status', render: (cls: any) => <Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>{cls.isActive ? 'Active' : 'Inactive'}</Badge> },
];
const actions = [
{
label: 'Edit',
onClick: (seatClass: any) => {
setEditingSeatClass(seatClass);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage seat class configurations</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setEditingSeatClass(null);
setShowModal(true);
}}
>
Add Seat Class
</ActionButton>
</div>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
</div>
</div>
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No seat classes found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, seatClass: null })}
onConfirm={confirmDelete}
title="Delete Seat Class"
message={`Are you sure you want to delete ${deleteConfirm.seatClass?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This seat class may be used by coaches and trips. Deleting it may impact fare calculations and seat assignments."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingSeatClass(null);
}}
title={`${editingSeatClass ? 'Edit' : 'Add'} Seat Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingSeatClass?.name}
required
placeholder="e.g., Economy Regular"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingSeatClass?.description}
placeholder="Describe the seat class..."
/>
</div>
<div>
<label className="label">Base Price (ETB) *</label>
<input
type="number"
name="basePrice"
className="input"
defaultValue={editingSeatClass?.basePrice ? (editingSeatClass.basePrice / 100).toFixed(2) : ''}
required
min="0"
step="0.01"
placeholder="e.g., 450.00"
/>
<p className="text-xs text-gray-500 mt-1">Enter amount in ETB (e.g., 450.00)</p>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingSeatClass?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingSeatClass(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingSeatClass ? 'Update' : 'Create'} Seat Class
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -4,14 +4,13 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { seatsApi, schedulesApi } from '@/lib/api';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react';
import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
export default function SeatsPage() {
const [search, setSearch] = useState('');
const [selectedSchedule, setSelectedSchedule] = useState('');
const [showBlockModal, setShowBlockModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [selectedSeat, setSelectedSeat] = useState<any>(null);
const [blockReason, setBlockReason] = useState('');
const queryClient = useQueryClient();
@@ -44,6 +43,22 @@ export default function SeatsPage() {
},
});
const removeSeatMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowRemoveModal(false);
setSelectedSeat(null);
},
});
const undoRemoveMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
},
});
const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || [];
@@ -58,6 +73,17 @@ export default function SeatsPage() {
}
};
const handleRemoveSeat = (seat: any) => {
setSelectedSeat(seat);
setShowRemoveModal(true);
};
const handleUndoRemove = async (seat: any) => {
if (confirm('Restore this removed seat?')) {
await undoRemoveMutation.mutateAsync(seat.id);
}
};
const submitBlock = async () => {
if (!blockReason.trim()) {
alert('Please provide a reason for blocking');
@@ -66,6 +92,10 @@ export default function SeatsPage() {
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
};
const submitRemoveSeat = async () => {
await removeSeatMutation.mutateAsync(selectedSeat.id);
};
const getSeatStatus = (seat: any) => {
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
@@ -83,9 +113,226 @@ export default function SeatsPage() {
}
};
const filteredCoaches = coaches.filter((coach: any) =>
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true
);
const parseSeatArrangement = (arrangement: string | null): number[] => {
if (!arrangement) return [2, 2];
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
return parts.length === 2 ? parts : [2, 2];
};
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
if (bedPosition === 'middle') return 'M';
if (bedPosition === 'lower') return 'L';
return '';
};
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
const allSeats = coach.seats || [];
const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
const removedSeats = allSeats.filter((s: any) => s.seatNumber && s.seatNumber.startsWith('-'));
if (validSeats.length === 0 && removedSeats.length === 0) {
return <div className="text-xs text-muted-foreground">No seats</div>;
}
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) {
// Render bed coach with flipping effect and bed position labels
const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];
const rows = [];
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || '');
const isVipBed = seatClassStr.toLowerCase().includes('vip');
const bedWidth = isVipBed ? 'w-24' : 'w-16';
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
}
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], idx: number) => {
const rowNumber = rowSeats[0]?.row || (idx + 1);
const shouldFlipIcon = rowNumber % 2 === 0;
const shouldFlipRow = rowNumber % 2 === 1;
const showSpacing = idx % 2 === 1;
return (
<div key={`bed-row-${idx}`}>
{shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-before-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
<div className="flex gap-0.5 justify-start">
{rowSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={true}
shouldFlipIcon={shouldFlipIcon}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
{!shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-after-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
{showSpacing && <div className="h-2" />}
</div>
);
})}
</div>
);
}
// Regular armchair layout
const arrangement = parseSeatArrangement(coach.seatArrangement);
const leftCount = arrangement[0];
const rightCount = arrangement[1] || 0;
const rows = [];
const processedRows = new Set();
const allSeatsForLayout = [...validSeats, ...removedSeats];
for (const seat of allSeatsForLayout) {
if (!processedRows.has(seat.row)) {
rows.push(allSeatsForLayout.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
const colA = a.col.charCodeAt(0);
const colB = b.col.charCodeAt(0);
return colA - colB;
}));
processedRows.add(seat.row);
}
}
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], rowIdx: number) => {
const leftSeats = rowSeats.slice(0, leftCount);
const rightSeats = rowSeats.slice(leftCount);
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipArmchair = rowNumber % 2 === 0;
const shouldFlipRow = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
<div key={`row-${rowSeats[0]?.id}`}>
{shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={false}
shouldFlipIcon={shouldFlipArmchair}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={false}
shouldFlipIcon={shouldFlipArmchair}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
)}
</div>
{!shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
)}
</div>
)}
{showSpacing && <div className="h-2" />}
</div>
);
})}
</div>
);
};
const coachesWithSeats = coaches.filter((coach: any) => {
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return seats.length > 0;
});
return (
<div className="space-y-6">
@@ -97,40 +344,25 @@ export default function SeatsPage() {
</div>
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="flex-1">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
</div>
<div className="relative flex-1">
<label className="label">Search Coaches</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by coach number..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
</div>
<div className="mb-6">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
</div>
{!selectedSchedule ? (
@@ -143,13 +375,12 @@ export default function SeatsPage() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
</div>
) : filteredCoaches.length === 0 ? (
) : coachesWithSeats.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No coaches found for this schedule</p>
<p>No coaches with seats found for this schedule</p>
</div>
) : (
<div className="space-y-6">
{/* Legend */}
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div>
@@ -167,86 +398,35 @@ export default function SeatsPage() {
<div className="w-4 h-4 rounded bg-gray-500"></div>
<span className="text-sm">Blocked</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm">Removed</span>
</div>
</div>
{/* Coaches */}
{filteredCoaches.map((coach: any) => {
const seats = coach.seats || [];
const seatClass = coach.seatClass?.name || 'N/A';
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length;
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{coachesWithSeats.map((coach: any) => {
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return (
<div key={coach.id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">
Coach {coach.coachNumber} - {coach.label}
</h3>
<p className="text-sm text-muted-foreground">
{seatClass} {seats.length} seats
</p>
return (
<div key={coach.id} className="flex flex-col gap-4">
<div className="mb-3">
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
</div>
<div className="flex gap-3 text-sm">
<span className="text-green-600">Available: {availableCount}</span>
<span className="text-red-600">Booked: {bookedCount}</span>
<span className="text-gray-600">Blocked: {blockedCount}</span>
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
{renderCoachSeats(coach, isBedCoach)}
</div>
</div>
<div className="grid grid-cols-8 gap-2">
{seats.map((seat: any) => {
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
return (
<div
key={seat.id}
className="relative group"
>
<div
className={`${color} text-white rounded-lg p-2 text-center text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity`}
title={`${seat.seatNumber} - ${status}`}
>
{seat.seatNumber}
</div>
{(canBlock || canUnblock) && (
<div className="absolute inset-0 bg-black/60 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
{canBlock && (
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Block seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
);
})}
</div>
</div>
)}
</div>
{/* Block Modal */}
<Modal
isOpen={showBlockModal}
onClose={() => {
@@ -293,6 +473,168 @@ export default function SeatsPage() {
</div>
</div>
</Modal>
<Modal
isOpen={showRemoveModal}
onClose={() => {
setShowRemoveModal(false);
setSelectedSeat(null);
}}
title="Remove Seat"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
This will mark the seat as removed. The seat will show as an empty space on the seat map.
You can undo this action anytime by clicking the undo button on the removed seat.
</p>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowRemoveModal(false);
setSelectedSeat(null);
}}
>
Cancel
</ActionButton>
<ActionButton
variant="danger"
onClick={submitRemoveSeat}
loading={removeSeatMutation.isPending}
>
Remove Seat
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}
interface SeatIconProps {
seat: any;
coach: any;
isBedCoach: boolean;
shouldFlipIcon?: boolean;
hideNumber?: boolean;
getSeatStatus: (seat: any) => string;
getSeatColor: (status: string) => string;
handleBlock: (seat: any) => void;
handleRemoveSeat: (seat: any) => void;
handleUnblock: (seat: any) => void;
handleUndoRemove: (seat: any) => void;
}
function SeatIcon({
seat,
coach,
isBedCoach,
shouldFlipIcon = false,
hideNumber = false,
getSeatStatus,
getSeatColor,
handleBlock,
handleRemoveSeat,
handleUnblock,
handleUndoRemove,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
const isVipBed = isBedCoach && seatClassStr.toLowerCase().includes('vip');
const bedWidth = isVipBed ? 'w-24' : 'w-16';
const width = isBedCoach ? bedWidth : 'w-10';
if (!seat.seatNumber) {
return <div className="w-7 h-7" />;
}
if (isRemoved) {
return (
<div className="relative group flex flex-col items-center">
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
</div>
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
<button
onClick={() => handleUndoRemove(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Undo remove"
>
<RotateCcw className="h-3 w-3 text-gray-700" />
</button>
</div>
</div>
);
}
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
return (
<div className="relative group flex flex-col items-center">
{!hideNumber && (
<span className="text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber}
</span>
)}
{isBedCoach ? (
<div
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
style={seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined}
>
<Bed className="w-7 h-7 text-white" />
</div>
) : (
<div
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${status}`}
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
>
<Armchair className="w-7 h-7 text-white" />
</div>
)}
{(canBlock || canUnblock) && (
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
{canBlock && (
<>
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Block seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
<button
onClick={() => handleRemoveSeat(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Remove seat"
>
<X className="h-3 w-3 text-gray-700" />
</button>
</>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -2,57 +2,388 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Search, Edit, Trash2 } from 'lucide-react';
import { Plus, Edit, Trash2, RefreshCw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { usersApi, BackofficeUser } from '@/lib/api/users';
export default function UserManagementPage() {
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState({ search: '', role: '', status: '', page: 1, pageSize: 10 });
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null });
const [resetPasswordModal, setResetPasswordModal] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null });
const [newPassword, setNewPassword] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['users', filters],
queryFn: () => usersApi.getAll(filters),
});
const createMutation = useMutation({
mutationFn: usersApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setShowModal(false);
setEditingUser(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => usersApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setShowModal(false);
setEditingUser(null);
},
});
const deleteMutation = useMutation({
mutationFn: usersApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const resetPasswordMutation = useMutation({
mutationFn: ({ id, tempPassword }: { id: string; tempPassword: string }) =>
usersApi.resetPassword(id, tempPassword),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setResetPasswordModal({ isOpen: false, user: null });
setNewPassword('');
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const userData = {
email: formData.get('email') as string,
fullName: formData.get('fullName') as string,
role: formData.get('role') as string,
status: formData.get('status') as 'ACTIVE' | 'INACTIVE',
} as any;
if (!editingUser) {
userData.password = formData.get('password') as string;
}
if (editingUser) {
await updateMutation.mutateAsync({ id: editingUser.id, data: userData });
} else {
await createMutation.mutateAsync(userData);
}
};
const handleDelete = (user: any) => {
setDeleteConfirm({ isOpen: true, user });
};
const confirmDelete = async () => {
if (deleteConfirm.user) {
await deleteMutation.mutateAsync(deleteConfirm.user.id);
setDeleteConfirm({ isOpen: false, user: null });
}
};
const handleResetPassword = async () => {
if (resetPasswordModal.user && newPassword) {
await resetPasswordMutation.mutateAsync({
id: resetPasswordModal.user.id,
tempPassword: newPassword,
});
}
};
const columns = [
{
key: 'fullName',
label: 'Full Name',
sortable: true,
render: (user: BackofficeUser) => (
<div>
<div className="font-medium">{user.fullName}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
</div>
),
},
{
key: 'role',
label: 'Role',
render: (user: BackofficeUser) => (
<Badge variant="status" status={user.role}>
{user.role}
</Badge>
),
},
{
key: 'status',
label: 'Status',
render: (user: BackofficeUser) => (
<Badge variant="status" status={user.status === 'ACTIVE' ? 'CONFIRMED' : 'CANCELLED'}>
{user.status}
</Badge>
),
},
{
key: 'lastLogin',
label: 'Last Login',
render: (user: BackofficeUser) => (
<span className="text-sm text-muted-foreground">
{user.lastLogin ? new Date(user.lastLogin).toLocaleString() : 'Never'}
</span>
),
},
];
const actions = [
{
label: 'Edit',
onClick: (user: BackofficeUser) => {
setEditingUser(user);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Reset Password',
onClick: (user: BackofficeUser) => {
setResetPasswordModal({ isOpen: true, user });
setNewPassword('');
},
variant: 'secondary' as const,
icon: RefreshCw,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground mt-1">Manage system users and permissions</p>
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground">Manage backoffice users and their permissions</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingUser(null);
setShowModal(true);
}}
>
Add User
</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<input
type="text"
placeholder="Search users by name or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pl-10"
placeholder="Search users..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div>
<div>
<select
className="input"
value={filters.role}
onChange={(e) => setFilters({ ...filters, role: e.target.value, page: 1 })}
>
<option value="">All Roles</option>
<option value="ADMIN">Admin</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="STAFF">Staff</option>
<option value="AGENT">Agent</option>
</select>
</div>
<div>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: 1 })}
>
<option value="">All Status</option>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
</div>
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Name</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Email</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Role</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
User management coming soon
</td>
</tr>
</tbody>
</table>
{/* Users Table */}
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No users found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, user: null })}
onConfirm={confirmDelete}
title="Delete User"
message={`Are you sure you want to delete ${deleteConfirm.user?.fullName}? This action cannot be undone.`}
confirmText="Delete"
isDanger={true}
/>
{/* Reset Password Modal */}
<Modal
isOpen={resetPasswordModal.isOpen}
onClose={() => setResetPasswordModal({ isOpen: false, user: null })}
title="Reset User Password"
>
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3 text-sm">
<p className="font-semibold text-blue-900 dark:text-blue-200">Temporary Password</p>
<p className="text-blue-800 dark:text-blue-300 mt-1">
Set a temporary password for {resetPasswordModal.user?.fullName}. They will need to change it on first login.
</p>
</div>
<div>
<label className="label">Temporary Password *</label>
<input
type="password"
className="input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter temporary password"
required
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => setResetPasswordModal({ isOpen: false, user: null })}
>
Cancel
</ActionButton>
<ActionButton
onClick={handleResetPassword}
loading={resetPasswordMutation.isPending}
disabled={!newPassword}
>
Reset Password
</ActionButton>
</div>
</div>
</div>
</Modal>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingUser(null);
}}
title={`${editingUser ? 'Edit' : 'Add'} User`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Full Name *</label>
<input
type="text"
name="fullName"
className="input"
defaultValue={editingUser?.fullName}
required
placeholder="e.g., John Doe"
/>
</div>
<div>
<label className="label">Email *</label>
<input
type="email"
name="email"
className="input"
defaultValue={editingUser?.email}
required
placeholder="e.g., john@example.com"
disabled={!!editingUser}
/>
</div>
<div>
<label className="label">Role *</label>
<select
name="role"
className="input"
defaultValue={editingUser?.role || 'STAFF'}
required
>
<option value="ADMIN">Admin</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="STAFF">Staff</option>
<option value="AGENT">Agent</option>
</select>
</div>
<div>
<label className="label">Status</label>
<select
name="status"
className="input"
defaultValue={editingUser?.status || 'ACTIVE'}
>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
{!editingUser && (
<div>
<label className="label">Password *</label>
<input
type="password"
name="password"
className="input"
required
placeholder="Minimum 8 characters"
minLength={8}
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingUser(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingUser ? 'Update' : 'Create'} User
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -279,7 +279,7 @@ export default function StationsPage() {
className="input"
defaultValue={editingStation?.name}
required
placeholder="e.g., Addis Ababa"
placeholder="e.g., Lebu"
/>
</div>
<div>

View File

@@ -19,13 +19,9 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll(filters),
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
});
if (error) {
console.error('Tickets API Error:', error);
}
const regenerateMutation = useMutation({
mutationFn: ticketsApi.regenerate,
onSuccess: () => {

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Train } from 'lucide-react';
import { Plus, Edit, Trash2, Train, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -15,6 +15,7 @@ import { formatDate } from '@/lib/utils';
export default function TrainsPage() {
const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
const queryClient = useQueryClient();
@@ -86,6 +87,19 @@ export default function TrainsPage() {
}
};
const trains = trainsData?.items || [];
const filteredTrains = trains.filter((train: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
train.number.toLowerCase().includes(searchLower) ||
train.name.toLowerCase().includes(searchLower) ||
train.operatorName?.toLowerCase().includes(searchLower) ||
train.description?.toLowerCase().includes(searchLower)
);
});
const trainColumns = [
{
key: 'number',
@@ -168,13 +182,27 @@ export default function TrainsPage() {
</ActionButton>
</div>
{/* Search Filter */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by number, name, or operator..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
</div>
{/* Trains Table */}
<DataTable
data={trainsData?.items || []}
data={filteredTrains}
columns={trainColumns}
actions={actions}
loading={trainsLoading}
emptyMessage="No trains found"
emptyMessage={search ? "No trains match your search" : "No trains found"}
/>
{/* Delete Confirmation */}
@@ -222,7 +250,7 @@ export default function TrainsPage() {
className="input"
defaultValue={editingTrain?.number}
required
placeholder="e.g., EDR-001"
placeholder="e.g., EDR-101"
/>
</div>
<div>

View File

@@ -57,12 +57,12 @@ const navigationSections = [
title: 'Master Data',
items: [
{ name: 'Stations', href: '/stations', icon: MapPin },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Trains', href: '/trains', icon: Train },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Classes', href: '/classes', icon: Settings },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Schedules', href: '/schedules', icon: Calendar },
{ name: 'Classes', href: '/seat-classes', icon: Settings },
]
},
{
@@ -70,7 +70,7 @@ const navigationSections = [
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Promotions', href: '/promotions', icon: Gift },
{ name: 'Promo Codes', href: '/promos', icon: Gift },
]
},
{

View File

@@ -7,8 +7,12 @@ interface PaginationProps {
}
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
return (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div className="flex items-center justify-between border-t border-border bg-card px-4 py-3 sm:px-6">
<div className="flex flex-1 justify-between sm:hidden">
<button
onClick={() => onPageChange(currentPage - 1)}
@@ -27,7 +31,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
</div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
<p className="text-sm text-foreground">
Page <span className="font-medium">{currentPage}</span> of{' '}
<span className="font-medium">{totalPages}</span>
</p>
@@ -51,4 +55,4 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
</div>
</div>
);
}
}

View File

@@ -0,0 +1,43 @@
import React, { ReactNode } from 'react';
interface TabItem {
id: string;
label: string;
icon?: ReactNode;
content: ReactNode;
}
interface TabsProps {
tabs: TabItem[];
defaultTab?: string;
}
export default function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = React.useState(defaultTab || tabs[0]?.id);
return (
<div>
<div className="border-b border-gray-200">
<div className="flex gap-1 -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
} flex items-center gap-2`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
<div className="mt-6">
{tabs.find((tab) => tab.id === activeTab)?.content}
</div>
</div>
);
}

View File

@@ -18,6 +18,29 @@ export const bookingsApi = {
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/bookings/${id}`),
getMy: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/bookings/my${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getByDevice: async (deviceId: string, params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries({ ...params }).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
cleanParams['deviceId'] = deviceId;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/bookings/by-device${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
@@ -124,6 +147,8 @@ export const seatsApi = {
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
};
// Payments API
@@ -144,7 +169,10 @@ export const paymentsApi = {
// Tickets API
export const ticketsApi = {
getAll: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString();
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/tickets${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
@@ -222,6 +250,9 @@ export const promotionsApi = {
delete: (id: string) => apiClient.delete(`/promos/${id}`),
};
export { promosApi } from './promos';
export { usersApi } from './users';
// Support API
export const supportApi = {
getConversations: async (params?: any) => {
@@ -306,13 +337,13 @@ export const liveApi = {
getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'),
};
// Seat Classes API
// Classes API (formerly Seat Classes)
export const seatClassesApi = {
getAll: () => apiClient.get<any[]>('/seat-classes'),
getById: (id: string) => apiClient.get<any>(`/seat-classes/${id}`),
create: (data: any) => apiClient.post<any>('/seat-classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/seat-classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/seat-classes/${id}`),
getAll: () => apiClient.get<any[]>('/fleet/classes'),
getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`),
create: (data: any) => apiClient.post<any>('/fleet/classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`),
};
// Food & Dining API

View File

@@ -7,6 +7,7 @@ export const passengersApi = {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.verified !== undefined) params.append('verified', filters.verified.toString());
if (filters?.role) params.append('role', filters.role);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());

View File

@@ -0,0 +1,54 @@
import { apiClient } from '@/lib/api-client';
export interface PromoCode {
id: string;
code: string;
title: string;
discountType: 'PERCENTAGE' | 'FIXED';
discountValue: number;
maxDiscount?: number;
minBookingAmount?: number;
maxUsagePerUser?: number;
totalUsageLimit?: number;
usageCount: number;
validFrom: string;
validUntil: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export const promosApi = {
getAll: (filters?: { search?: string; active?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.active) params.append('active', filters.active);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
return apiClient.get<{ items: PromoCode[]; total: number; page: number; pageSize: number }>(`/promos/all?${params.toString()}`);
},
getById: (id: string) => {
return apiClient.get<PromoCode>(`/promos/${id}`);
},
create: (data: Omit<PromoCode, 'id' | 'createdAt' | 'updatedAt' | 'usageCount'>) => {
return apiClient.post<PromoCode>('/promos', data);
},
update: (id: string, data: Partial<PromoCode>) => {
return apiClient.patch<PromoCode>(`/promos/${id}`, data);
},
delete: (id: string) => {
return apiClient.delete<void>(`/promos/${id}`);
},
validate: (code: string, bookingAmount?: number) => {
return apiClient.post<{ valid: boolean; message?: string; discount?: number }>('/promos/validate', {
code,
bookingAmount,
});
},
};

View File

@@ -0,0 +1,59 @@
import { apiClient } from '@/lib/api-client';
export interface BackofficeUser {
id: string;
email: string;
fullName: string;
role: 'ADMIN' | 'SUPERVISOR' | 'STAFF' | 'AGENT';
status: 'ACTIVE' | 'INACTIVE';
lastLogin?: string;
createdAt: string;
updatedAt: string;
}
export const usersApi = {
getAll: async (filters?: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.role) params.append('role', filters.role);
if (filters?.status) params.append('status', filters.status);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
const response = await apiClient.get<any>(`/auth/users?${params.toString()}`);
// Handle different response formats
if (response && typeof response === 'object') {
if ('items' in response) {
return response as { items: BackofficeUser[]; total: number };
}
if (Array.isArray(response)) {
return { items: response as BackofficeUser[], total: response.length };
}
}
return { items: Array.isArray(response) ? response : [], total: 0 };
},
getById: (id: string) => {
return apiClient.get<BackofficeUser>(`/auth/users/${id}`);
},
create: (data: { email: string; fullName: string; role: string; password: string }) => {
return apiClient.post<BackofficeUser>('/auth/users', data);
},
update: (id: string, data: Partial<BackofficeUser>) => {
return apiClient.patch<BackofficeUser>(`/auth/users/${id}`, data);
},
delete: (id: string) => {
return apiClient.delete<void>(`/auth/users/${id}`);
},
resetPassword: (id: string, tempPassword: string) => {
return apiClient.post<{ success: boolean; message: string }>(`/auth/users/${id}/reset-password`, {
tempPassword,
});
},
};

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