diff --git a/.github/scripts/scan.js b/.github/scripts/scan.js new file mode 100644 index 000000000..7b1924b3f --- /dev/null +++ b/.github/scripts/scan.js @@ -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 5–8 KB instantly. + maxLegitConfigBytes: 3072, + + // Minimum whitespace run on a single line that signals hidden payload. + // The campaign uses ~280–510 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 5–8 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...]", + ); + 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(); diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dbf44509b..e46409ba6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.github/workflows/polinrider-scan.yml b/.github/workflows/polinrider-scan.yml new file mode 100644 index 000000000..594f181d1 --- /dev/null +++ b/.github/workflows/polinrider-scan.yml @@ -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] + # ... diff --git a/.gitignore b/.gitignore index 9f6cafc6d..bb9b43556 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,6 @@ coverage/ .idea/ .vscode/ .npmrc -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat # emacs cache files *~ diff --git a/CLAUDE.md b/CLAUDE.md index c06000dac..d67e6c3d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 00ca421d1..2818dcd97 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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:->/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` diff --git a/README.md b/README.md index 4d7bd604b..125561b9d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 241c12d63..1a9c71990 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -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 diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 07a03fc2c..4ff9ba2c6 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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", diff --git a/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql deleted file mode 100644 index d5b4c9fa2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260524080651_add_nationality_and_waafi/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterEnum -ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI'; - --- AlterTable -ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql deleted file mode 100644 index 1698c0c23..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260524091255_add_guest_booking_support/migration.sql +++ /dev/null @@ -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"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql deleted file mode 100644 index da38b0502..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql deleted file mode 100644 index 0b22437b5..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260525202029_remover_userid_from_paymentmethod/migration.sql +++ /dev/null @@ -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"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql deleted file mode 100644 index 64c577ff5..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT, -ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB'; diff --git a/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql deleted file mode 100644 index ed8665647..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260523064555_initia/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql similarity index 89% rename from apps/edr-passenger-api/prisma/migrations/20260523064555_initia/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql index 2883715d8..0f8484179 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260523064555_initia/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql new file mode 100644 index 000000000..525b6572e --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql b/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql deleted file mode 100644 index fc3d83a8d..000000000 --- a/apps/edr-passenger-api/prisma/migrations/add_guest_booking_support.sql +++ /dev/null @@ -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)'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index cee8dfee2..34fe198d2 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -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 diff --git a/apps/edr-passenger-api/prisma/seed-complete.ts b/apps/edr-passenger-api/prisma/seed-complete.ts deleted file mode 100644 index f9cc064be..000000000 --- a/apps/edr-passenger-api/prisma/seed-complete.ts +++ /dev/null @@ -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(); - }); diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 8a9707c8e..17283e15b 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -1,831 +1,614 @@ import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; +import { randomUUID as uuidv4 } from 'crypto'; const prisma = new PrismaClient(); -// ============================================================================ -// SECTION 1: STATIONS (18 STATIONS) -// ============================================================================ -async function seedStations() { - console.log('📍 Seeding 18 stations...'); - - const stations = [ - { 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: 'FTO', name: 'Feto', city: 'Feto', countryCode: 'ET', lat: 8.4500, lng: 39.4000 }, - { code: 'MTH', name: 'Metahara', city: 'Metahara', countryCode: 'ET', lat: 8.9000, lng: 39.9167 }, - { code: 'MSO', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 9.2400, lng: 40.7500 }, - { code: 'BKE', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.4200, lng: 41.2000 }, - { code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 }, - { code: 'ARW', name: 'Arawa', city: 'Arawa', countryCode: 'ET', lat: 10.2000, lng: 42.1500 }, - { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 10.8500, lng: 42.4000 }, - { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 }, - { code: 'DWL', name: 'Dawanle', city: 'Dawanle', countryCode: 'DJ', lat: 11.4000, lng: 42.9500 }, - { code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 11.1667, lng: 42.7167 }, - { code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.3500, lng: 43.0500 }, - { code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 }, - ]; +const EDR_ROUTE_ID = uuidv4(); +const TRAIN_ID = uuidv4(); - const created = []; - for (const station of stations) { - const s = await prisma.station.upsert({ - where: { code: station.code }, - update: {}, - create: station, - }); - created.push(s); - } - - console.log(` ✅ Created ${created.length} stations`); - return created; -} - -// ============================================================================ -// SECTION 2: SEAT CLASSES -// ============================================================================ -async function seedSeatClasses() { - console.log('💺 Seeding seat classes...'); - - const classes = [ - { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 25000 }, - { name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 35000 }, - { name: 'VIP Bed', description: 'First class VIP bed', basePrice: 55000 }, - ]; - - const created = []; - for (const cls of classes) { - const c = await prisma.seatClass.upsert({ - where: { name: cls.name }, - update: {}, - create: { ...cls, isActive: true }, - }); - created.push(c); - } - - console.log(` ✅ Created ${created.length} seat classes`); - return created; -} - -// ============================================================================ -// SECTION 3: TRAINS -// ============================================================================ -async function seedTrains() { - console.log('🚂 Seeding trains...'); - - const trains = [ - { number: '301', name: 'Express 301', description: 'Sebeta-Nagad Express' }, - { number: '302', name: 'Express 302', description: 'Nagad-Sebeta Express' }, - { number: '303', name: 'Local 303', description: 'Regional Service' }, - ]; - - const created = []; - for (const train of trains) { - const t = await prisma.train.upsert({ - where: { number: train.number }, - update: {}, - create: train, - }); - created.push(t); - } - - console.log(` ✅ Created ${created.length} trains`); - return created; -} - -// ============================================================================ -// SECTION 4: COACHES & SEATS -// ============================================================================ -async function seedCoachesAndSeats(seatClasses: any[]) { - console.log('🚃 Seeding coaches and seats...'); - - const [scEconomy, scEconomyBed, scVip] = seatClasses; - - const coachConfigs = [ - { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 }, - { coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 }, - { coachNumber: 'C-C1', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 }, - { coachNumber: 'C-A2', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 }, - { coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 }, - { coachNumber: 'C-C2', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 }, - ]; - - const coaches = []; - for (const config of coachConfigs) { - const coach = await prisma.coach.upsert({ - where: { coachNumber: config.coachNumber }, - update: {}, - create: config, - }); - coaches.push(coach); - - // Create seats for this coach - const existingSeats = await prisma.seat.count({ where: { coachId: coach.id } }); - if (existingSeats === 0) { - const seats = []; - const rows = Math.ceil(config.totalUnits / 4); - for (let row = 1; row <= rows; row++) { - for (const col of ['A', 'B', 'C', 'D']) { - if (seats.length >= config.totalUnits) break; - seats.push({ - coachId: coach.id, - row, - col, - label: `${row}${col}`, - seatNumber: `${config.label}${row}${col}`, - kind: row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD', - }); - } - } - await prisma.seat.createMany({ data: seats as any }); - } - } - - console.log(` ✅ Created ${coaches.length} coaches with seats`); - return coaches; -} - -// ============================================================================ -// SECTION 5: SCHEDULES (15+ SEGMENTS) -// ============================================================================ -async function seedSchedules(trains: any[], stations: any[], routes: any[]) { - console.log('📅 Seeding schedules with 15+ segments...'); - - const [train301, train302, train303] = trains; - const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; - const [fullRoute, regionalRoute] = routes; - - // Clean up existing schedules - const existingScheduleIds = (await prisma.trainSchedule.findMany({ - where: { trainId: { in: [train301.id, train302.id, train303.id] } }, - select: { id: true }, - })).map((s: { id: string }) => s.id); - - if (existingScheduleIds.length > 0) { - // Delete in correct order to avoid foreign key constraints - await prisma.bookingSeat.deleteMany({ - where: { - booking: { - scheduleId: { in: existingScheduleIds } - } - } - }); - await prisma.booking.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); - await prisma.fareRule.deleteMany({ where: { tripId: { in: existingScheduleIds } } }); - await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); - await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } }); - await prisma.trainSchedule.deleteMany({ where: { id: { in: existingScheduleIds } } }); - } - - const schedules = [ - // Full route: Sebeta to Nagad (18 stations) - { - trainId: train301.id, - routeId: fullRoute.id, - originStationId: sebeta.id, - destinationStationId: nagad.id, - departureAt: new Date('2026-06-15T06:00:00Z'), - arrivalAt: new Date('2026-06-15T22:00:00Z'), - durationMinutes: 960, - stopsCount: 18, - }, - // Return route: Nagad to Sebeta - { - trainId: train302.id, - routeId: fullRoute.id, - originStationId: nagad.id, - destinationStationId: sebeta.id, - departureAt: new Date('2026-06-16T07:00:00Z'), - arrivalAt: new Date('2026-06-16T23:30:00Z'), - durationMinutes: 990, - stopsCount: 18, - }, - // Regional service: Sebeta to Diredawa - { - trainId: train303.id, - routeId: regionalRoute.id, - originStationId: sebeta.id, - destinationStationId: diredawa.id, - departureAt: new Date('2026-06-17T08:00:00Z'), - arrivalAt: new Date('2026-06-17T18:00:00Z'), - durationMinutes: 600, - stopsCount: 11, - }, - // Additional schedules for next day - { - trainId: train301.id, - routeId: fullRoute.id, - originStationId: sebeta.id, - destinationStationId: nagad.id, - departureAt: new Date('2026-06-18T06:30:00Z'), - arrivalAt: new Date('2026-06-18T22:45:00Z'), - durationMinutes: 975, - stopsCount: 18, - }, - { - trainId: train302.id, - routeId: fullRoute.id, - originStationId: nagad.id, - destinationStationId: sebeta.id, - departureAt: new Date('2026-06-19T07:15:00Z'), - arrivalAt: new Date('2026-06-19T23:45:00Z'), - durationMinutes: 990, - stopsCount: 18, - }, - ]; - - const created = []; - for (const schedule of schedules) { - const s = await prisma.trainSchedule.create({ data: schedule }); - created.push(s); - } - - console.log(` ✅ Created ${created.length} schedules`); - return created; -} - -// ============================================================================ -// SECTION 6: COACH ASSIGNMENTS -// ============================================================================ -async function seedCoachAssignments(schedules: any[], coaches: any[]) { - console.log('🔗 Seeding coach assignments...'); - - const [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2] = coaches; - const [schedule1, schedule2, schedule3] = schedules; - - const assignments = [ - { scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 }, - { scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 }, - { scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 }, - { scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 }, - { scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 }, - { scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 }, - { scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 }, - { scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 }, - { scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 }, - ]; - - await prisma.coachAssignment.createMany({ data: assignments, skipDuplicates: true }); - console.log(` ✅ Created ${assignments.length} coach assignments`); -} - -// ============================================================================ -// SECTION 7: STOP TIMES (ALL 18 STATIONS) -// ============================================================================ -async function seedStopTimes(schedules: any[], stations: any[]) { - console.log('⏱️ Seeding stop times for all stations...'); - - const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; - const [schedule1, schedule2, schedule3] = schedules; - - // Full route stop times (Sebeta to Nagad) - const fullRouteStops = [ - { scheduleId: schedule1.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T06:30:00Z'), plannedDepartureAt: new Date('2026-06-15T06:35:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T07:30:00Z'), plannedDepartureAt: new Date('2026-06-15T07:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T08:15:00Z'), plannedDepartureAt: new Date('2026-06-15T08:25:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:15:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T09:45:00Z'), plannedDepartureAt: new Date('2026-06-15T09:50:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T10:30:00Z'), plannedDepartureAt: new Date('2026-06-15T10:45:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-15T12:00:00Z'), plannedDepartureAt: new Date('2026-06-15T12:10:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-15T13:30:00Z'), plannedDepartureAt: new Date('2026-06-15T13:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: arawa.id, sequence: 12, plannedArrivalAt: new Date('2026-06-15T16:30:00Z'), plannedDepartureAt: new Date('2026-06-15T16:35:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: adigala.id, sequence: 13, plannedArrivalAt: new Date('2026-06-15T17:45:00Z'), plannedDepartureAt: new Date('2026-06-15T17:50:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: aysha.id, sequence: 14, plannedArrivalAt: new Date('2026-06-15T18:30:00Z'), plannedDepartureAt: new Date('2026-06-15T18:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: dawanle.id, sequence: 15, plannedArrivalAt: new Date('2026-06-15T19:15:00Z'), plannedDepartureAt: new Date('2026-06-15T19:20:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: alisabieh.id, sequence: 16, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), plannedDepartureAt: new Date('2026-06-15T20:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: holhol.id, sequence: 17, plannedArrivalAt: new Date('2026-06-15T21:00:00Z'), plannedDepartureAt: new Date('2026-06-15T21:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule1.id, stationId: nagad.id, sequence: 18, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' as const }, - ]; - - // Regional route stop times (Sebeta to Diredawa) - const regionalStops = [ - { scheduleId: schedule3.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-17T08:00:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-17T08:30:00Z'), plannedDepartureAt: new Date('2026-06-17T08:35:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-17T09:00:00Z'), plannedDepartureAt: new Date('2026-06-17T09:05:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-17T09:30:00Z'), plannedDepartureAt: new Date('2026-06-17T09:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-17T10:15:00Z'), plannedDepartureAt: new Date('2026-06-17T10:25:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-17T11:00:00Z'), plannedDepartureAt: new Date('2026-06-17T11:15:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-17T11:45:00Z'), plannedDepartureAt: new Date('2026-06-17T11:50:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-17T12:30:00Z'), plannedDepartureAt: new Date('2026-06-17T12:45:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-17T14:00:00Z'), plannedDepartureAt: new Date('2026-06-17T14:10:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-17T15:30:00Z'), plannedDepartureAt: new Date('2026-06-17T15:40:00Z'), status: 'UPCOMING' as const }, - { scheduleId: schedule3.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-17T18:00:00Z'), status: 'UPCOMING' as const }, - ]; - - const allStops = [...fullRouteStops, ...regionalStops]; - await prisma.tripStopTime.createMany({ data: allStops }); - console.log(` ✅ Created ${allStops.length} stop times`); -} - -// ============================================================================ -// SECTION 8: FARE RULES (COMPREHENSIVE SEGMENTS) -// ============================================================================ -async function seedFareRules(schedules: any[], seatClasses: any[]) { - console.log('💰 Seeding comprehensive fare rules...'); - - const [scEconomy, scEconomyBed, scVip] = seatClasses; - - // Segment-based fare rules (15+ segments) - const segmentRules = [ - // Short segments (1-3 stations) - { route: 'SBT-LBU', seatClassId: scEconomy.id, baseFareMinor: 5000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'LBU-IND', seatClassId: scEconomy.id, baseFareMinor: 4500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'IND-BSH', seatClassId: scEconomy.id, baseFareMinor: 5500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'BSH-MJO', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'MJO-ADM', seatClassId: scEconomy.id, baseFareMinor: 7000, validFrom: new Date('2026-01-01'), refundable: true }, - - // Medium segments (3-6 stations) - { route: 'SBT-BSH', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-ADM', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'ADM-MTH', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'MTH-MSO', seatClassId: scEconomy.id, baseFareMinor: 9500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'MSO-BKE', seatClassId: scEconomy.id, baseFareMinor: 8000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'BKE-DDW', seatClassId: scEconomy.id, baseFareMinor: 7500, validFrom: new Date('2026-01-01'), refundable: true }, - - // Long segments (6+ stations) - { route: 'SBT-DDW', seatClassId: scEconomy.id, baseFareMinor: 35000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'DDW-AYS', seatClassId: scEconomy.id, baseFareMinor: 15000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'AYS-NGD', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-NGD', seatClassId: scEconomy.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true }, - - // Cross-border segments - { route: 'DDW-DWL', seatClassId: scEconomy.id, baseFareMinor: 22000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'DWL-ALI', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'ALI-HOL', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'HOL-NGD', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true }, - ]; - - // Add Economy Bed prices (40% higher) - const bedRules = segmentRules.map(rule => ({ - ...rule, - seatClassId: scEconomyBed.id, - baseFareMinor: Math.round(rule.baseFareMinor * 1.4), - })); - - // Add VIP prices (80% higher) - const vipRules = segmentRules.map(rule => ({ - ...rule, - seatClassId: scVip.id, - baseFareMinor: Math.round(rule.baseFareMinor * 1.8), - })); - - const allRules = [...segmentRules, ...bedRules, ...vipRules]; - await prisma.fareRule.createMany({ data: allRules, skipDuplicates: true }); - - // Nationality-specific discounts - const nationalityRules = [ - // Ethiopian nationals - 10% discount on domestic routes - { route: 'SBT-DDW', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 31500, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-ADM', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 16200, validFrom: new Date('2026-01-01'), refundable: true }, - - // Djiboutian nationals - 5% discount on cross-border routes - { route: 'DDW-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 42750, validFrom: new Date('2026-01-01'), refundable: true }, - { route: 'SBT-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 61750, validFrom: new Date('2026-01-01'), refundable: true }, - ]; - - await prisma.fareRule.createMany({ data: nationalityRules, skipDuplicates: true }); - - console.log(` ✅ Created ${allRules.length + nationalityRules.length} fare rules`); -} - -// ============================================================================ -// SECTION 9: USERS & PASSENGERS -// ============================================================================ -async function seedUsers() { - console.log('👥 Seeding users...'); - - const hash = await bcrypt.hash('password123', 10); +async function seedSystemUsers() { + console.log('👥 Seeding system users...'); const adminHash = await bcrypt.hash('admin123', 10); + const passengerHash = await bcrypt.hash('password123', 10); const agentHash = await bcrypt.hash('agent123', 10); + const supervisorHash = await bcrypt.hash('supervisor123', 10); + const staffHash = await bcrypt.hash('staff123', 10); - // Admin - await prisma.user.upsert({ + const admin = await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: adminHash, role: 'ADMIN' }, create: { - fullName: 'EDR Admin', + fullName: 'System Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN', }, }); + console.log(' ✅ Admin: admin@edr-platform.com / admin123'); - // Ethiopian Passenger - const ethiopianUser = await prisma.user.upsert({ - where: { email: 'abebe@email.com' }, - update: {}, + const passenger = await prisma.user.upsert({ + where: { email: 'kelemu@email.com' }, + update: { passwordHash: passengerHash }, create: { - fullName: 'Abebe Kebede', - email: 'abebe@email.com', - phone: '+251912345678', - passwordHash: hash, + fullName: 'Kelemu Kebede', + email: 'kelemu@email.com', + phone: '+251911234567', + passwordHash: passengerHash, + role: 'PASSENGER', nationality: 'Ethiopian', - nationalId: 'ET123456789', + faydaVerified: true, }, }); - let ethiopianPassenger = await prisma.passenger.findUnique({ where: { userId: ethiopianUser.id } }); - if (!ethiopianPassenger) { - ethiopianPassenger = await prisma.passenger.create({ data: { userId: ethiopianUser.id } }); - await prisma.loyaltyAccount.create({ data: { passengerId: ethiopianPassenger.id, pointsBalance: 2450, tier: 'SILVER' } }); - await prisma.walletAccount.create({ data: { passengerId: ethiopianPassenger.id, balanceMinor: 125000 } }); + let passengerRecord = await prisma.passenger.findUnique({ where: { userId: passenger.id } }); + if (!passengerRecord) { + passengerRecord = await prisma.passenger.create({ data: { userId: passenger.id } }); + await prisma.loyaltyAccount.create({ + data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' }, + }); + await prisma.walletAccount.create({ + data: { passengerId: passengerRecord.id, balanceMinor: 50000 }, + }); } await prisma.userPreferences.upsert({ - where: { userId: ethiopianUser.id }, + where: { userId: passenger.id }, update: {}, - create: { userId: ethiopianUser.id, language: 'en' }, + create: { userId: passenger.id, language: 'en' }, }); + console.log(' ✅ Passenger: kelemu@email.com / password123'); - // Djiboutian Passenger - const djiboutianUser = await prisma.user.upsert({ - where: { email: 'ahmed@email.com' }, - update: {}, - create: { - fullName: 'Ahmed Hassan', - email: 'ahmed@email.com', - phone: '+25377123456', - passwordHash: hash, - nationality: 'Djiboutian', - passportNumber: 'DJ1234567', - }, - }); - - let djiboutianPassenger = await prisma.passenger.findUnique({ where: { userId: djiboutianUser.id } }); - if (!djiboutianPassenger) { - djiboutianPassenger = await prisma.passenger.create({ data: { userId: djiboutianUser.id } }); - await prisma.loyaltyAccount.create({ data: { passengerId: djiboutianPassenger.id, pointsBalance: 1200, tier: 'BRONZE' } }); - await prisma.walletAccount.create({ data: { passengerId: djiboutianPassenger.id, balanceMinor: 85000 } }); - } - await prisma.userPreferences.upsert({ - where: { userId: djiboutianUser.id }, - update: {}, - create: { userId: djiboutianUser.id, language: 'fr' }, - }); - - // Agent - const agentUser = await prisma.user.upsert({ + const agent = await prisma.user.upsert({ where: { email: 'agent@edr-platform.com' }, update: { passwordHash: agentHash, role: 'AGENT' }, create: { - fullName: 'Agent Abebe', + fullName: 'Booking Agent', email: 'agent@edr-platform.com', phone: '+251911111111', passwordHash: agentHash, role: 'AGENT', }, }); - - const stations = await prisma.station.findMany(); await prisma.agent.upsert({ - where: { userId: agentUser.id }, + where: { userId: agent.id }, update: {}, + create: { userId: agent.id, agentCode: 'AG0001', commissionRate: 5 }, + }); + console.log(' ✅ Agent: agent@edr-platform.com / agent123'); + + const supervisor = await prisma.user.upsert({ + where: { email: 'supervisor@edr-platform.com' }, + update: { passwordHash: supervisorHash, role: 'SUPERVISOR' }, create: { - userId: agentUser.id, - agentCode: 'AG001', - stationId: stations[0].id, - commissionRate: 5, - active: true, + fullName: 'System Supervisor', + email: 'supervisor@edr-platform.com', + phone: '+251922222222', + passwordHash: supervisorHash, + role: 'SUPERVISOR', }, }); + console.log(' ✅ Supervisor: supervisor@edr-platform.com / supervisor123'); - console.log(` ✅ Created 4 users (Admin, Ethiopian, Djiboutian, Agent)`); + const staff = await prisma.user.upsert({ + where: { email: 'staff@edr-platform.com' }, + update: { passwordHash: staffHash, role: 'STAFF' }, + create: { + fullName: 'Support Staff', + email: 'staff@edr-platform.com', + phone: '+251933333333', + passwordHash: staffHash, + role: 'STAFF', + }, + }); + console.log(' ✅ Staff: staff@edr-platform.com / staff123'); } -// ============================================================================ -// SECTION 10: ROUTES -// ============================================================================ -async function seedRoutes(stations: any[], seatClasses: any[]) { - console.log('🛤️ Seeding routes...'); - - const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations; - const [scEconomy, scEconomyBed, scVip] = seatClasses; - - // Route 1: Full Line (Sebeta to Nagad) - const fullRoute = await prisma.route.upsert({ - where: { code: 'SBT-NGD-FULL' }, - update: {}, - create: { - code: 'SBT-NGD-FULL', - name: 'Sebeta - Nagad Express', - description: 'Complete Ethio-Djibouti Railway route from Sebeta to Nagad', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - // Create stops for full route - const fullRouteStops = [ - { routeId: fullRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 }, - { routeId: fullRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 }, - { routeId: fullRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 }, - { routeId: fullRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 }, - { routeId: fullRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 }, - { routeId: fullRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 }, - { routeId: fullRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 }, - { routeId: fullRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 }, - { routeId: fullRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 }, - { routeId: fullRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 }, - { routeId: fullRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 }, - { routeId: fullRoute.id, stationId: arawa.id, sequence: 12, distanceKm: 445 }, - { routeId: fullRoute.id, stationId: adigala.id, sequence: 13, distanceKm: 512 }, - { routeId: fullRoute.id, stationId: aysha.id, sequence: 14, distanceKm: 578 }, - { routeId: fullRoute.id, stationId: dawanle.id, sequence: 15, distanceKm: 625 }, - { routeId: fullRoute.id, stationId: alisabieh.id, sequence: 16, distanceKm: 672 }, - { routeId: fullRoute.id, stationId: holhol.id, sequence: 17, distanceKm: 718 }, - { routeId: fullRoute.id, stationId: nagad.id, sequence: 18, distanceKm: 756 }, +async function seedStations() { + console.log('\n📍 Seeding 15 stations (Ethio-Djibouti Railway)...'); + const stations = [ + { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150 }, + { code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320 }, + { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240 }, + { code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130 }, + { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780 }, + { code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920 }, + { code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450 }, + { code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670 }, + { code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578 }, + { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150 }, + { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670 }, + { code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340 }, + { code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560 }, + { code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450 }, + { code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200 }, ]; - await prisma.routeStop.createMany({ data: fullRouteStops, skipDuplicates: true }); - // Fare rules for full route - const fullRouteFares = [ - { routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') }, - { routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: fullRouteFares, skipDuplicates: true }); - - // Route 2: Regional (Sebeta to Diredawa) - const regionalRoute = await prisma.route.upsert({ - where: { code: 'SBT-DDW-REG' }, - update: {}, - create: { - code: 'SBT-DDW-REG', - name: 'Sebeta - Diredawa Regional', - description: 'Regional service from Sebeta to Diredawa', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - const regionalStops = [ - { routeId: regionalRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 }, - { routeId: regionalRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 }, - { routeId: regionalRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 }, - { routeId: regionalRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 }, - { routeId: regionalRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 }, - { routeId: regionalRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 }, - { routeId: regionalRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 }, - { routeId: regionalRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 }, - { routeId: regionalRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 }, - { routeId: regionalRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 }, - { routeId: regionalRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 }, - ]; - await prisma.routeStop.createMany({ data: regionalStops, skipDuplicates: true }); - - const regionalFares = [ - { routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - { routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: regionalFares, skipDuplicates: true }); - - // Route 3: Short Distance (Sebeta to Adama) - const shortRoute = await prisma.route.upsert({ - where: { code: 'SBT-ADM-SHORT' }, - update: {}, - create: { - code: 'SBT-ADM-SHORT', - name: 'Sebeta - Adama Commuter', - description: 'Short distance commuter service', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - const shortStops = [ - { routeId: shortRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 }, - { routeId: shortRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 }, - { routeId: shortRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 }, - { routeId: shortRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 }, - { routeId: shortRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 }, - { routeId: shortRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 }, - ]; - await prisma.routeStop.createMany({ data: shortStops, skipDuplicates: true }); - - const shortFares = [ - { routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') }, - { routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: shortFares, skipDuplicates: true }); - - // Route 4: Cross-Border (Diredawa to Nagad) - const crossBorderRoute = await prisma.route.upsert({ - where: { code: 'DDW-NGD-INTL' }, - update: {}, - create: { - code: 'DDW-NGD-INTL', - name: 'Diredawa - Nagad International', - description: 'Cross-border service from Ethiopia to Djibouti', - effectiveFrom: new Date('2026-01-01'), - active: true, - }, - }); - - const crossBorderStops = [ - { routeId: crossBorderRoute.id, stationId: diredawa.id, sequence: 1, distanceKm: 0 }, - { routeId: crossBorderRoute.id, stationId: arawa.id, sequence: 2, distanceKm: 67 }, - { routeId: crossBorderRoute.id, stationId: adigala.id, sequence: 3, distanceKm: 134 }, - { routeId: crossBorderRoute.id, stationId: aysha.id, sequence: 4, distanceKm: 200 }, - { routeId: crossBorderRoute.id, stationId: dawanle.id, sequence: 5, distanceKm: 247 }, - { routeId: crossBorderRoute.id, stationId: alisabieh.id, sequence: 6, distanceKm: 294 }, - { routeId: crossBorderRoute.id, stationId: holhol.id, sequence: 7, distanceKm: 340 }, - { routeId: crossBorderRoute.id, stationId: nagad.id, sequence: 8, distanceKm: 378 }, - ]; - await prisma.routeStop.createMany({ data: crossBorderStops, skipDuplicates: true }); - - const crossBorderFares = [ - { routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') }, - { routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') }, - ]; - await prisma.routeFareRule.createMany({ data: crossBorderFares, skipDuplicates: true }); - - console.log(` ✅ Created 4 routes with stops and fare rules`); - return [fullRoute, regionalRoute, shortRoute, crossBorderRoute]; + for (const station of stations) { + await prisma.station.upsert({ + where: { code: station.code }, + update: {}, + create: { + id: uuidv4(), + ...station, + }, + }); + } + console.log(` ✅ ${stations.length} stations created`); + return stations; } -// ============================================================================ -// SECTION 11: SUPPORTING DATA -// ============================================================================ -async function seedSupportingData(seatClasses: any[]) { - console.log('📦 Seeding supporting data...'); - - // Baggage Allowance - await prisma.baggageAllowance.deleteMany({}); - await prisma.baggageAllowance.createMany({ - data: [ - { seatClassId: seatClasses[0].id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 }, - { seatClassId: seatClasses[1].id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 }, - { seatClassId: seatClasses[2].id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 }, - ], - }); +async function seedCoachTypesAndClasses() { + console.log('\n🚂 Seeding coach types and seat classes...'); + const coachTypes = [ + { code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' }, + { code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' }, + { code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' }, + ]; - // Supported Payment Methods (platform-wide catalog) - const paymentMethods = [ - { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 1, isDefault: true }, - { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 2 }, - { type: 'EBIRR', displayName: 'E-Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 3 }, - { type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI', currency: 'DJF', sortOrder: 4 }, - { type: 'CARD', displayName: 'Credit / Debit Card', region: 'INTERNATIONAL', currency: 'USD', sortOrder: 5 }, - { type: 'WALLET', displayName: 'EDR Wallet', region: 'GLOBAL', currency: 'ETB', sortOrder: 6 }, - ] as const; - for (const pm of paymentMethods) { - await prisma.paymentMethod.upsert({ - where: { type: pm.type as any }, - update: { displayName: pm.displayName, region: pm.region as any, currency: pm.currency, sortOrder: pm.sortOrder, enabled: true }, - create: { ...pm, region: pm.region as any, type: pm.type as any }, + for (const ct of coachTypes) { + await prisma.coachType.upsert({ + where: { id: ct.code }, + update: {}, + create: { ...ct, id: ct.code }, }); } - // Notification Templates - await prisma.notificationTemplate.upsert({ - where: { code: 'BOOKING_CONFIRMED' }, - update: {}, - create: { - code: 'BOOKING_CONFIRMED', - channel: 'EMAIL', - subject: 'Booking Confirmed', - bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{tripDate}}.', - active: true, - }, - }); + const seatClasses = [ + { name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 }, + { name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 }, + { name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 }, + { name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550 }, + { name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500 }, + { name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250 }, + ]; - await prisma.notificationTemplate.upsert({ - where: { code: 'booking.created' }, - update: {}, - create: { - code: 'booking.created', - channel: 'EMAIL', - subject: 'Booking Created', - bodyTemplate: 'Your booking {{bookingRef}} has been created successfully.', - active: true, - }, - }); - - await prisma.notificationTemplate.upsert({ - where: { code: 'PAYMENT_SUCCESS' }, - update: {}, - create: { - code: 'PAYMENT_SUCCESS', - channel: 'SMS', - bodyTemplate: 'Payment successful for {{bookingRef}}. Amount: {{amount}} ETB', - active: true, - }, - }); - - // Promotions - await prisma.promotion.upsert({ - where: { code: 'WEEKEND15' }, - update: {}, - create: { - title: 'Weekend Sale', - subtitle: '15% off all trips', - code: 'WEEKEND15', - percentOff: 15, - validUntil: new Date('2026-12-31'), - ctaLabel: 'Book Now', - active: true, - }, - }); - - // Currency Exchange Rates - 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() }, - { fromCurrency: 'DJF', toCurrency: 'ETB', rate: 0.3125, effectiveDate: new Date() }, - { fromCurrency: 'DJF', toCurrency: 'DJF', rate: 1.0, effectiveDate: new Date() }, - ], - }); - - // Fraud Rules - await prisma.fraudRule.upsert({ - where: { type: 'VELOCITY' }, - update: {}, - create: { - type: 'VELOCITY', - enabled: true, - threshold: 3, - config: { windowMinutes: 60, action: 'FLAG' }, - }, - }); - - console.log(` ✅ Created supporting data`); + for (const sc of seatClasses) { + const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } }); + await prisma.seatClass.upsert({ + where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } }, + update: {}, + create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor }, + }); + } + console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`); +} + +async function seedRoute() { + console.log('\n🛣️ Seeding route and stops...'); + const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } }); + const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } }); + + const route = await prisma.route.upsert({ + where: { code: 'EDR-101' }, + update: {}, + create: { + code: 'EDR-101', + name: 'Sebeta - Dire Dawa', + description: 'Outbound local route from Sebeta to Dire Dawa', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE']; + for (let i = 0; i < stationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } }, + update: {}, + create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: i * 85 }, + }); + } + console.log(` ✅ Route with ${stationCodes.length} stops created`); +} + +async function seedCoaches() { + console.log('\n🚃 Seeding coaches and seats...'); + const ecoCoachType = await prisma.coachType.findUnique({ where: { id: 'HSC' } }); + const ecoBedCoachType = await prisma.coachType.findUnique({ where: { id: 'HBC' } }); + const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } }); + + const coaches = [ + { number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 }, + { number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 }, + { number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 }, + ]; + + let totalSeats = 0; + for (const coach of coaches) { + const c = await prisma.coach.upsert({ + where: { number: coach.number }, + update: {}, + create: coach, + }); + + // Idempotently reconcile the coach's seats. Upsert keyed on the + // @@unique([coachId, row, col]) constraint so a re-seed updates existing + // rows in place instead of deleting them. Deleting Seats fails with a P2003 + // FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them. + let seatIndex = 1; + for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) { + for (const col of ['A', 'B', 'C', 'D']) { + if (seatIndex > coach.capacity) break; + let bedPosition: string | null = null; + if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) { + if (row % 3 === 1) bedPosition = 'upper'; + else if (row % 3 === 2) bedPosition = 'middle'; + else bedPosition = 'lower'; + } + + const seatData = { + seatNumber: seatIndex.toString(), + isWindow: col === 'A' || col === 'D', + isAisle: col === 'B' || col === 'C', + bedPosition, + }; + + await prisma.seat.upsert({ + where: { coachId_row_col: { coachId: c.id, row, col } }, + update: seatData, + create: { coachId: c.id, row, col, ...seatData }, + }); + seatIndex++; + } + } + totalSeats += coach.capacity; + } + console.log(` ✅ ${coaches.length} coaches with ${totalSeats} seats created`); +} + +async function seedTrips() { + console.log('\n🚆 Seeding train service, schedule, and trips...'); + const train = await prisma.train.upsert({ + where: { number: 'EDR-001' }, + update: {}, + create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' }, + }); + + const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } }); + const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } }); + const lastStation = await prisma.station.findUnique({ where: { code: 'DRE' } }); + const coaches = await prisma.coach.findMany(); + + const now = new Date(); + const schedules = []; + + for (let d = 0; d < 30; d++) { + const tripDate = new Date(now); + tripDate.setDate(tripDate.getDate() + d); + tripDate.setHours(8, 0, 0, 0); + + const departureAt = new Date(tripDate); + const arrivalAt = new Date(departureAt.getTime() + 4 * 24 * 60 * 60 * 1000); + + schedules.push({ + trainId: train.id, + routeId: route!.id, + originStationId: firstStation!.id, + destinationStationId: lastStation!.id, + departureAt, + arrivalAt, + durationMinutes: 4 * 24 * 60, + stopsCount: 15, + }); + } + + const createdSchedules = await Promise.all( + schedules.map(s => prisma.trainSchedule.create({ data: s })) + ); + + // Create TripStopTimes for each schedule + const routeStops = await prisma.routeStop.findMany({ + where: { routeId: route!.id }, + orderBy: { sequence: 'asc' }, + include: { route: true }, + }); + + for (const schedule of createdSchedules) { + const stopTimes = []; + for (const routeStop of routeStops) { + const minutesFromStart = (routeStop.sequence - 1) * 480; // 8 hours per stop + const plannedDepartureAt = new Date(schedule.departureAt.getTime() + minutesFromStart * 60_000); + const plannedArrivalAt = new Date(plannedDepartureAt.getTime() + 30 * 60_000); // 30 min stop + + stopTimes.push({ + scheduleId: schedule.id, + stationId: routeStop.stationId, + sequence: routeStop.sequence, + plannedArrivalAt, + plannedDepartureAt, + }); + } + + await Promise.all( + stopTimes.map(st => prisma.tripStopTime.create({ data: st })) + ); + } + + const coachAssignments = []; + const liveStatuses = []; + + for (const schedule of createdSchedules) { + for (let p = 0; p < coaches.length; p++) { + coachAssignments.push({ + scheduleId: schedule.id, + coachId: coaches[p].id, + positionNumber: p + 1, + }); + } + liveStatuses.push({ + scheduleId: schedule.id, + state: 'scheduled', + progressPercent: 0, + }); + } + + await Promise.all([ + ...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })), + ...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })), + ]); + + console.log(` ✅ Train with ${createdSchedules.length} upcoming trips created`); +} + +async function seedFareRules() { + console.log('\n💰 Seeding fare rules...'); + const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } }); + const seatClasses = await prisma.seatClass.findMany(); + const validFrom = new Date('2024-01-01'); + + const fareRules = []; + for (const sc of seatClasses) { + fareRules.push({ + routeId: route!.id, + seatClassId: sc.id, + passengerCategory: 'ADULT' as const, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + fareRules.push({ + routeId: route!.id, + seatClassId: sc.id, + passengerCategory: 'CHILD' as const, + baseFareMinor: Math.floor(sc.baseFareMinor * 0.5), + discountPercent: 50, + currency: 'ETB', + validFrom, + }); + } + + await Promise.all( + fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) + ); + console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`); +} + +async function seedCurrency() { + console.log('\n💱 Seeding currency exchange rates...'); + const rates = [ + { from: 'ETB', to: 'DJF', rate: 3.25 }, + { from: 'ETB', to: 'USD', rate: 0.018 }, + { from: 'DJF', to: 'ETB', rate: 0.3077 }, + { from: 'USD', to: 'ETB', rate: 55.56 }, + ]; + + for (const r of rates) { + await prisma.currencyExchangeRate.upsert({ + where: { + fromCurrency_toCurrency_effectiveDate: { + fromCurrency: r.from as any, + toCurrency: r.to as any, + effectiveDate: new Date('2024-01-01'), + }, + }, + update: { rate: r.rate }, + create: { + fromCurrency: r.from as any, + toCurrency: r.to as any, + rate: r.rate, + effectiveDate: new Date('2024-01-01'), + }, + }); + } + console.log(` ✅ 4 currency exchange rates created`); +} + +async function seedPaymentMethods() { + console.log('\n💳 Seeding payment methods...'); + const methods = [ + { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, + { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, + { type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' }, + { type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' }, + { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, + ]; + + for (const m of methods) { + await prisma.paymentMethod.upsert({ + where: { type: m.type as any }, + update: {}, + create: { ...m, type: m.type as any, region: m.region as any }, + }); + } + console.log(` ✅ ${methods.length} payment methods created`); +} + +async function seedNotificationTemplates() { + console.log('\n🔔 Seeding notification templates...'); + const templates = [ + { id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed' }, + { id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment received for {{bookingRef}}' }, + { id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip departs in {{minutes}} minutes' }, + { id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip is delayed by {{delayMinutes}} minutes' }, + { id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' }, + ]; + + for (const t of templates) { + await prisma.notificationTemplate.upsert({ + where: { code: t.code }, + update: {}, + create: t, + }); + } + console.log(` ✅ ${templates.length} notification templates created`); +} + +async function seedMenuAndFood() { + console.log('\n🍽️ Seeding menu categories and items...'); + const beverages = await prisma.menuCategory.upsert({ + where: { id: uuidv4() }, + update: {}, + create: { id: uuidv4(), name: 'Beverages' }, + }); + const snacks = await prisma.menuCategory.upsert({ + where: { id: uuidv4() }, + update: {}, + create: { id: uuidv4(), name: 'Snacks' }, + }); + + const schedule = await prisma.trainSchedule.findFirst(); + if (schedule) { + const coffeeId = uuidv4(); + const juiceId = uuidv4(); + const sandwichId = uuidv4(); + + await prisma.menuItem.create({ + data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 5000 }, + }).catch(() => {}); // ignore if exists + await prisma.menuItem.create({ + data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 3500 }, + }).catch(() => {}); // ignore if exists + await prisma.menuItem.create({ + data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 8000 }, + }).catch(() => {}); // ignore if exists + } + console.log(` ✅ Menu categories and items created`); +} + +async function seedPromotions() { + console.log('\n🎉 Seeding promotions...'); + const promos = [ + { id: uuidv4(), title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, + { id: uuidv4(), title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) }, + { id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 10000, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) }, + ]; + + for (const p of promos) { + await prisma.promotion.upsert({ + where: { code: p.code }, + update: {}, + create: p, + }); + } + console.log(` ✅ ${promos.length} promotions created`); +} + +async function seedFAQ() { + console.log('\n❓ Seeding FAQ...'); + const generalId = uuidv4(); + const bookingId = uuidv4(); + const general = await prisma.faqCategory.upsert({ + where: { id: generalId }, + update: {}, + create: { id: generalId, title: 'General', iconKey: 'help' }, + }); + const booking = await prisma.faqCategory.upsert({ + where: { id: bookingId }, + update: {}, + create: { id: bookingId, title: 'Booking', iconKey: 'book' }, + }); + + await prisma.faqArticle.upsert({ + where: { id: uuidv4() }, + update: {}, + create: { id: uuidv4(), categoryId: general.id, question: 'What is EDR?', answerMarkdown: 'Ethio-Djibouti Railway' }, + }); + await prisma.faqArticle.upsert({ + where: { id: uuidv4() }, + update: {}, + create: { id: uuidv4(), categoryId: booking.id, question: 'How to book?', answerMarkdown: 'Use the booking system' }, + }); + console.log(` ✅ FAQ categories and articles created`); +} + +async function seedFraudRules() { + console.log('\n🔐 Seeding fraud detection rules...'); + const rules = [ + { type: 'RAPID_BOOKINGS', threshold: 10, enabled: true }, + { type: 'HIGH_VALUE_BOOKING', threshold: 500000, enabled: true }, + { type: 'UNUSUAL_DEVICE', threshold: 0.8, enabled: true }, + ]; + + for (const r of rules) { + await prisma.fraudRule.upsert({ + where: { type: r.type }, + update: {}, + create: r, + }); + } + console.log(` ✅ ${rules.length} fraud detection rules created`); +} + +// Run a seed step in isolation: if it throws (FK conflict, duplicate row, +// missing record, etc.) log the error and keep going so the rest of the seed — +// and the API startup that follows it — are never blocked by one bad step. +async function runStep(name: string, step: () => Promise): Promise { + try { + await step(); + return true; + } catch (e) { + console.error(`⚠️ Seed step "${name}" failed — skipping and continuing:`, e); + return false; + } } -// ============================================================================ -// MAIN SEED FUNCTION -// ============================================================================ async function main() { - console.log('🌱 Starting comprehensive modular seed with 18 stations...\n'); + console.log('🌱 Comprehensive EDR Seed Starting...\n'); - const stations = await seedStations(); - const seatClasses = await seedSeatClasses(); - const trains = await seedTrains(); - const coaches = await seedCoachesAndSeats(seatClasses); - const routes = await seedRoutes(stations, seatClasses); - const schedules = await seedSchedules(trains, stations, routes); - await seedCoachAssignments(schedules, coaches); - await seedStopTimes(schedules, stations); - await seedFareRules(schedules, seatClasses); - await seedUsers(); - await seedSupportingData(seatClasses); + const steps: Array<[string, () => Promise]> = [ + ['system users', seedSystemUsers], + ['stations', seedStations], + ['coach types & classes', seedCoachTypesAndClasses], + ['route', seedRoute], + ['coaches', seedCoaches], + ['trips', seedTrips], + ['fare rules', seedFareRules], + ['currency', seedCurrency], + ['payment methods', seedPaymentMethods], + ['notification templates', seedNotificationTemplates], + ['menu & food', seedMenuAndFood], + ['promotions', seedPromotions], + ['FAQ', seedFAQ], + ['fraud rules', seedFraudRules], + ]; - console.log('\n✅ Comprehensive seed complete!\n'); - console.log('📋 Seed Summary:'); - console.log(' - 18 Stations: SBT, LBU, IND, BSH, MJO, ADM, FTO, MTH, MSO, BKE, DDW, ARW, ADG, AYS, DWL, ALI, HOL, NGD'); - console.log(' - 3 Seat Classes (Economy Regular, Economy Bed, VIP Bed)'); - console.log(' - 3 Trains (Express 301, Express 302, Local 303)'); - console.log(' - 6 Physical Coaches with seats'); - console.log(' - 5 Train Schedules covering full and regional routes'); - console.log(' - 4 Routes with stops and fare rules'); - console.log(' - 15+ Fare Segments with nationality-based pricing'); - console.log(' - 4 Users: Admin, Ethiopian Passenger, Djiboutian Passenger, Agent'); - console.log(' - Currency rates: ETB, USD, DJF'); - console.log('\n🔑 Login Credentials:'); - console.log(' Admin: admin@edr-platform.com / admin123'); - console.log(' Ethiopian Passenger: abebe@email.com / password123'); - console.log(' Djiboutian Passenger: ahmed@email.com / password123'); - console.log(' Agent: agent@edr-platform.com / agent123'); - console.log('\n💰 Booking Flow Ready:'); - console.log(' - Search: 18 stations with multiple route combinations'); - console.log(' - Select: 3 seat classes with dynamic pricing'); - console.log(' - Book: Complete passenger details and payment'); - console.log(' - Pay: Multiple payment methods (Telebirr, CBE, Card, Wallet)'); - console.log(' - Ticket: QR code generation and validation'); - console.log('\n🚂 Sample Routes:'); - console.log(' - Full Route: Sebeta → Nagad (18 stations, 756 km)'); - console.log(' - Regional: Sebeta → Diredawa (11 stations, 378 km)'); - console.log(' - Short: Sebeta → Adama (6 stations, 99 km)'); - console.log(' - Cross-border: Diredawa → Nagad (8 stations, 378 km)'); + let failed = 0; + for (const [name, step] of steps) { + if (!(await runStep(name, step))) failed++; + } + + if (failed > 0) { + console.warn(`\n⚠️ Seed finished with ${failed}/${steps.length} step(s) failed (see logs above).\n`); + } else { + console.log('\n✅ Seed complete!\n'); + } + console.log('🔑 System Users:'); + console.log(' Admin: admin@edr-platform.com / admin123'); + console.log(' Passenger: kelemu@email.com / password123'); + console.log(' Agent: agent@edr-platform.com / agent123'); + console.log(' Supervisor: supervisor@edr-platform.com / supervisor123'); + console.log(' Staff: staff@edr-platform.com / staff123'); } main() .catch((e) => { - console.error('❌ Seed failed:', e); - process.exit(1); + // Intentionally do NOT process.exit(1): the docker entrypoint runs under + // `set -e`, so a non-zero exit here would abort container startup and the + // API would never boot. Log and exit cleanly instead. + console.error('❌ Seed crashed unexpectedly — continuing so the API can start:', e); }) .finally(async () => { await prisma.$disconnect(); diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 1bbc56aaa..b747b77c1 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -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(), diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index f02ce4abf..e50172db9 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -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(); diff --git a/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..b47876d4c --- /dev/null +++ b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts @@ -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(); + 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; + } +} diff --git a/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts b/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts index d1cd41c7b..83f810966 100644 --- a/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts +++ b/apps/edr-passenger-api/src/common/interceptors/response-transform.interceptor.ts @@ -4,7 +4,14 @@ import { map } from 'rxjs/operators'; @Injectable() export class ResponseTransformInterceptor implements NestInterceptor { - intercept(_ctx: ExecutionContext, next: CallHandler): Observable { + intercept(ctx: ExecutionContext, next: CallHandler): Observable { + // 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() })), ); diff --git a/apps/edr-passenger-api/src/config/rabbitmq.config.ts b/apps/edr-passenger-api/src/config/rabbitmq.config.ts new file mode 100644 index 000000000..d6d5ee335 --- /dev/null +++ b/apps/edr-passenger-api/src/config/rabbitmq.config.ts @@ -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), +})); diff --git a/apps/edr-passenger-api/src/config/waafi.config.ts b/apps/edr-passenger-api/src/config/waafi.config.ts index c3afab7ce..639e66e2b 100644 --- a/apps/edr-passenger-api/src/config/waafi.config.ts +++ b/apps/edr-passenger-api/src/config/waafi.config.ts @@ -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', })); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index d85b17dbe..a7bd85f81 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -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(); diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index 4d091656f..0565faf8f 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -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); + } } diff --git a/apps/edr-passenger-api/src/modules/auth/auth.service.ts b/apps/edr-passenger-api/src/modules/auth/auth.service.ts index 74bd3f79b..e937a106b 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.service.ts @@ -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, + })), }; } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index d21777a1b..d7f87550f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -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({ diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 256e974a7..58c7b6cea 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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 }, })), diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index ef66c6be1..93cbda7b0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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: { diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index e104a507f..1f9717c6a 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -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, diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index b0411718f..182709446 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -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( diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index e26fb2211..940978f88 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -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(); + } } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 6046b1068..d0875d79e 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index daea3a9dd..043a3531b 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -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 = { - 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(); - 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, + }; } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 8864a6363..39fd235a4 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -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, }; } -} \ No newline at end of file +} diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts new file mode 100644 index 000000000..98262c4c3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -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 { + return this.paymentsService.handlePaymentEvent(event); + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts new file mode 100644 index 000000000..7f732ae51 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts new file mode 100644 index 000000000..7b1789ae9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -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 { + 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 { + 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( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + this.logger.log("====================================================================="); + this.logger.log(`URL ${url}`); + this.logger.log("====================================================================="); + try { + const response = await firstValueFrom( + this.http.request({ + 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"); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts new file mode 100644 index 000000000..291402240 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -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 { + 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); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts index b686269c5..0397e62ab 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts @@ -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 { - 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 { + 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 { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; +} +export async function eBirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; +} +export async function cardAdapter( + _a: number, + ref: string, +): Promise { + 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 { + return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } -export async function cbeBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; } -export async function eBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; } -export async function cardAdapter(_a: number, ref: string): Promise { 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 { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 49b570ab1..373e10513 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -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, '"'); + const escaped = url.replace(/\"/g, """); return ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 9d8467c3f..309bd4a0a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 78ffe2196..0fd594b39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -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); 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/*). }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 1f8086ac3..056986613 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -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("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("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 {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index d4a35e14f..1a2ebdf1f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -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); 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, ); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index f6c5ba458..fe80ae88e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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; 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.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 { @@ -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 { - 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>, ): 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 { - 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 { - 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 { + 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 }); + } } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.types.ts b/apps/edr-passenger-api/src/modules/payments/payments.types.ts index 686274065..2e9463ad3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.types.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.types.ts @@ -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"; diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts deleted file mode 100644 index 5bf977107..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts +++ /dev/null @@ -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 { - const merchantOrderId = payload.data.object.metadata.merchantOrderId; - const externalEventId = `${payload.id}_${payload.type}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - 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 { - await this.prisma.paymentWebhookEvent.update({ - where: { id: eventId }, - data: { processedAt: new Date(), processingError }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts deleted file mode 100644 index 42502e941..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts +++ /dev/null @@ -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 { - const merchantOrderId = payload.merchantOrderId; - const externalEventId = `${payload.orderId}_${payload.status}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - 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 { - await this.prisma.paymentWebhookEvent.update({ - where: { id: eventId }, - data: { processedAt: new Date(), processingError }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts deleted file mode 100644 index ace727a2a..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts +++ /dev/null @@ -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 { - const merchantOrderId = payload.orderNo; - const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - 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 { - await this.prisma.paymentWebhookEvent.update({ - where: { id: eventId }, - data: { processedAt: new Date(), processingError }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts deleted file mode 100644 index 1f2b06c3e..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts +++ /dev/null @@ -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 { - 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, - // ); - 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 { - 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); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts deleted file mode 100644 index 972b09b3d..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ /dev/null @@ -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, - ); - - 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 }; - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts deleted file mode 100644 index 4dd340d06..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ /dev/null @@ -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' }; - } -} diff --git a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts index ccb76e051..8bdf8f868 100644 --- a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts +++ b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts @@ -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) { + 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); + } } diff --git a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts index 421269f5c..759fe371e 100644 --- a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts +++ b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts @@ -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; } diff --git a/apps/edr-passenger-api/src/modules/promos/promos.service.ts b/apps/edr-passenger-api/src/modules/promos/promos.service.ts index 97a8ed275..141d80b57 100644 --- a/apps/edr-passenger-api/src/modules/promos/promos.service.ts +++ b/apps/edr-passenger-api/src/modules/promos/promos.service.ts @@ -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 & { 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)); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 3b3fd9b83..bb8792a5b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -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({ diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 64ab95384..0752e7565 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -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[]; +} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 820c5b8a0..af03937f8 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -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' }; } -} +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 19eb25652..29254892d 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -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 = {}; 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> { - // 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> { - 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 }, ]; diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 982c37209..3f2fafebf 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index e5d61c829..8914ca465 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -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); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 014e0918a..a21797e0f 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -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], diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 6abd4e11a..88f9666bd 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -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 { + 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 { 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; + } + } + } } } diff --git a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts index 8b46a77d1..7e912a6e2 100644 --- a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts +++ b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts @@ -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})`); } } diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts index 88fd636b6..406c9e61b 100644 --- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts @@ -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, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 881b95e0a..5711355e0 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -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); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index f1c01ab9d..cd02974d3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -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'; diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 54e994765..a77714cf3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -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 }; diff --git a/apps/edr-passenger-web/backoffice/next.config.js b/apps/edr-passenger-web/backoffice/next.config.js index a286d1a26..5690be409 100644 --- a/apps/edr-passenger-web/backoffice/next.config.js +++ b/apps/edr-passenger-web/backoffice/next.config.js @@ -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, }, }; diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index a86c23f51..654e19ef9 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -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" }, diff --git a/apps/edr-passenger-web/backoffice/src/app/seat-classes/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/layout.tsx similarity index 100% rename from apps/edr-passenger-web/backoffice/src/app/seat-classes/layout.tsx rename to apps/edr-passenger-web/backoffice/src/app/classes/layout.tsx diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx new file mode 100644 index 000000000..5939ab882 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -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(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null }); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['classes', filters], + queryFn: () => seatClassesApi.getAll(), + }); + + const { data: coachTypes } = useQuery({ + 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) => { + 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) => ( + {cls.coachType?.name || coachTypeMap[cls.coachTypeId] || 'N/A'} + ), + }, + { + key: 'name', + label: 'Class Name', + render: (cls: any) => {cls.name}, + }, + { + key: 'description', + label: 'Description', + render: (cls: any) => ( + {cls.description || '-'} + ), + }, + { + key: 'baseFareMinor', + label: 'Base Fare (ETB)', + render: (cls: any) => ( + {formatCurrency(cls.baseFareMinor, 'ETB')} + ), + }, + { + key: 'isActive', + label: 'Status', + render: (cls: any) => ( + + {cls.isActive ? 'Active' : 'Inactive'} + + ), + }, + ]; + + 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 ( +
+
+
+

Classes

+

Manage class configurations by coach type

+
+ handleOpenModal()} + > + Add Class + +
+ +
+
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + + + {/* Delete Confirmation */} + 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 */} + { + setShowModal(false); + setEditingClass(null); + setSelectedCoachTypeId(''); + }} + title={`${editingClass ? 'Edit' : 'Add'} Class`} + size="lg" + > +
+
+
+ + +
+ +
+ + +
+ +
+ +