feat: Runtime configuration and Docker deployment improvements

Frontend:
- Add runtime configuration service for deployment-time API URL injection
- Create docker-entrypoint.sh to generate config.json from environment variables
- Update ApiService, ApprovalService, and DocumentViewer to use RuntimeConfigService
- Add APP_INITIALIZER to load runtime config before app starts

Backend:
- Fix init-blockchain.js to properly quote mnemonic phrases in .env file
- Improve docker-entrypoint.sh with health checks and better error handling

Docker:
- Add API_BASE_URL environment variable to frontend container
- Update docker-compose.yml with clear documentation for remote deployment
- Reorganize .env.example with clear categories (REQUIRED FOR REMOTE, PRODUCTION, AUTO-GENERATED)

Workflow fixes:
- Fix DepartmentApproval interface to match backend schema
- Fix stage transformation for 0-indexed stageOrder
- Fix workflow list to show correct stage count from definition.stages

Cleanup:
- Move development artifacts to .trash directory
- Remove root-level package.json (was only for utility scripts)
- Add .trash/ to .gitignore
This commit is contained in:
Mahi
2026-02-08 18:44:05 -04:00
parent 2c10cd5662
commit d9de183e51
171 changed files with 10236 additions and 8386 deletions

View File

@@ -1,30 +1,70 @@
# Blockchain Smart Contract Addresses # ==============================================================================
# These will be populated after deploying contracts # Goa GEL Platform - Environment Configuration
# ==============================================================================
#
# LOCAL DEV: No config needed, just run: docker-compose up -d
# REMOTE/VM: Set the [REQUIRED FOR REMOTE] values below
# PRODUCTION: Set ALL security values with strong passwords
#
# ==============================================================================
# ==============================================================================
# [REQUIRED FOR REMOTE] External Access URLs
# ==============================================================================
# Set these when deploying to a VM, server, or Kubernetes
# These are the URLs that browsers/external clients use to reach your services
# Public URL where the API is accessible (used by frontend in browser)
# Examples: http://192.168.1.100:3001, https://api.goagel.gov.in
API_BASE_URL=http://localhost:3001/api/v1
# Allowed origins for CORS (frontend URL)
# Examples: http://192.168.1.100:4200, https://goagel.gov.in
CORS_ORIGIN=http://localhost:4200
# ==============================================================================
# [REQUIRED FOR PRODUCTION] Security Credentials
# ==============================================================================
# Change ALL of these for production - do not use defaults!
# JWT secret (minimum 32 characters)
# Generate: openssl rand -base64 32
JWT_SECRET=dev_jwt_secret_change_in_production_min32chars
# Database password
DATABASE_PASSWORD=postgres_dev_password
# MinIO storage credentials
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minio_dev_password
# Blockscout explorer secret
# Generate: openssl rand -base64 64
BLOCKSCOUT_SECRET_KEY_BASE=RMgI4C1HSkxsEjdhtGMfwAHfyT6CKWXOgzCboJflfSm4jeAlic52io05KB6mqzc5
# ==============================================================================
# [AUTO-GENERATED] Blockchain Contract Addresses
# ==============================================================================
# Populated after deploying smart contracts: cd blockchain && npm run deploy
CONTRACT_ADDRESS_LICENSE_NFT= CONTRACT_ADDRESS_LICENSE_NFT=
CONTRACT_ADDRESS_APPROVAL_MANAGER= CONTRACT_ADDRESS_APPROVAL_MANAGER=
CONTRACT_ADDRESS_DEPARTMENT_REGISTRY= CONTRACT_ADDRESS_DEPARTMENT_REGISTRY=
CONTRACT_ADDRESS_WORKFLOW_REGISTRY= CONTRACT_ADDRESS_WORKFLOW_REGISTRY=
# Platform Wallet Private Key
# This will be generated during initial setup
PLATFORM_WALLET_PRIVATE_KEY= PLATFORM_WALLET_PRIVATE_KEY=
# Database Configuration (optional overrides)
# DATABASE_HOST=postgres
# DATABASE_PORT=5432
# DATABASE_NAME=goa_gel_platform
# DATABASE_USER=postgres
# DATABASE_PASSWORD=postgres_secure_password
# Redis Configuration (optional overrides) # ==============================================================================
# REDIS_HOST=redis # [OPTIONAL] Advanced Settings
# REDIS_PORT=6379 # ==============================================================================
# MinIO Configuration (optional overrides) # NODE_ENV=production
# MINIO_ENDPOINT=minio # FORCE_RESEED=false
# MINIO_PORT=9000
# MINIO_ACCESS_KEY=minioadmin
# MINIO_SECRET_KEY=minioadmin_secure
# JWT Secret (change in production) # External ports (if defaults conflict)
# JWT_SECRET=your-super-secure-jwt-secret-key-min-32-chars-long # API_PORT=3001
# FRONTEND_PORT=4200
# BLOCKSCOUT_PORT=4000

7
.gitignore vendored
View File

@@ -39,3 +39,10 @@ session-backups/
# Archives # Archives
*.zip *.zip
# Trash folder
.trash/
# Test results
test-results/
frontend/test-results/

View File

@@ -1,764 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Goa GEL Platform - Demo Presentation</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #0f172a;
color: #e2e8f0;
overflow: hidden;
}
.slide {
display: none;
min-height: 100vh;
padding: 60px 80px;
animation: fadeIn 0.5s ease;
}
.slide.active {
display: flex;
flex-direction: column;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 3.5rem;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 20px;
}
h2 {
font-size: 2.5rem;
color: #6366f1;
margin-bottom: 30px;
border-bottom: 3px solid #6366f1;
padding-bottom: 10px;
}
h3 {
font-size: 1.5rem;
color: #a855f7;
margin: 20px 0 10px;
}
p, li {
font-size: 1.4rem;
line-height: 1.8;
color: #cbd5e1;
}
ul {
list-style: none;
margin: 20px 0;
}
li {
padding: 10px 0;
padding-left: 30px;
position: relative;
}
li::before {
content: "▸";
position: absolute;
left: 0;
color: #6366f1;
}
.title-slide {
justify-content: center;
align-items: center;
text-align: center;
}
.title-slide h1 {
font-size: 4.5rem;
margin-bottom: 30px;
}
.title-slide .subtitle {
font-size: 2rem;
color: #94a3b8;
margin-bottom: 50px;
}
.title-slide .tagline {
font-size: 1.5rem;
color: #6366f1;
border: 2px solid #6366f1;
padding: 15px 40px;
border-radius: 50px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 40px;
margin-top: 30px;
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.card {
background: linear-gradient(135deg, #1e293b, #334155);
padding: 30px;
border-radius: 16px;
border: 1px solid #475569;
}
.card h3 {
margin-top: 0;
}
.architecture-diagram {
background: #1e293b;
border-radius: 16px;
padding: 40px;
margin: 30px 0;
text-align: center;
}
.arch-row {
display: flex;
justify-content: center;
gap: 20px;
margin: 20px 0;
}
.arch-box {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
padding: 20px 40px;
border-radius: 12px;
font-weight: 600;
font-size: 1.1rem;
min-width: 150px;
}
.arch-box.frontend {
background: linear-gradient(135deg, #06b6d4, #0891b2);
}
.arch-box.backend {
background: linear-gradient(135deg, #10b981, #059669);
}
.arch-box.database {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
.arch-box.blockchain {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
}
.arch-arrow {
font-size: 2rem;
color: #6366f1;
}
.tech-stack {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 20px;
}
.tech-badge {
background: #334155;
padding: 10px 20px;
border-radius: 8px;
font-size: 1rem;
border: 1px solid #475569;
}
.feature-icon {
font-size: 3rem;
margin-bottom: 15px;
}
.navigation {
position: fixed;
bottom: 30px;
right: 40px;
display: flex;
gap: 15px;
z-index: 100;
}
.nav-btn {
background: #6366f1;
color: white;
border: none;
padding: 15px 25px;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s ease;
}
.nav-btn:hover {
background: #4f46e5;
transform: scale(1.05);
}
.slide-counter {
position: fixed;
bottom: 30px;
left: 40px;
font-size: 1rem;
color: #64748b;
}
.logo {
position: fixed;
top: 20px;
right: 40px;
font-size: 1rem;
color: #64748b;
}
.highlight {
color: #6366f1;
font-weight: 600;
}
.flow-diagram {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
margin: 30px 0;
}
.flow-step {
background: linear-gradient(135deg, #1e293b, #334155);
padding: 20px 30px;
border-radius: 12px;
border: 2px solid #6366f1;
text-align: center;
}
.flow-arrow {
color: #6366f1;
font-size: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
padding: 15px 20px;
text-align: left;
border-bottom: 1px solid #475569;
}
th {
background: #334155;
color: #6366f1;
font-size: 1.2rem;
}
td {
font-size: 1.1rem;
}
.status-badge {
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.status-complete {
background: #10b981;
color: white;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: start;
}
</style>
</head>
<body>
<div class="logo">Government of Goa</div>
<!-- Slide 1: Title -->
<div class="slide active" id="slide1">
<div class="title-slide" style="flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<h1>Goa GEL Platform</h1>
<p class="subtitle">Blockchain-Powered e-Licensing System</p>
<p class="tagline">Transparent • Secure • Efficient</p>
</div>
</div>
<!-- Slide 2: Problem Statement -->
<div class="slide" id="slide2">
<h2>The Challenge</h2>
<div class="grid">
<div class="card">
<div class="feature-icon">📋</div>
<h3>Manual Processes</h3>
<p>Paper-based applications causing delays and inefficiencies</p>
</div>
<div class="card">
<div class="feature-icon">🔍</div>
<h3>Lack of Transparency</h3>
<p>Citizens unable to track application status in real-time</p>
</div>
<div class="card">
<div class="feature-icon">🏢</div>
<h3>Siloed Departments</h3>
<p>No unified system for multi-department approvals</p>
</div>
<div class="card">
<div class="feature-icon">📄</div>
<h3>Document Tampering</h3>
<p>No mechanism to verify authenticity of issued licenses</p>
</div>
</div>
</div>
<!-- Slide 3: Solution Overview -->
<div class="slide" id="slide3">
<h2>Our Solution</h2>
<p style="font-size: 1.6rem; margin-bottom: 30px;">A unified blockchain-powered platform for government e-licensing</p>
<div class="grid">
<div class="card">
<div class="feature-icon">🌐</div>
<h3>Digital Portal</h3>
<p>Single window for all license applications with role-based access</p>
</div>
<div class="card">
<div class="feature-icon">⛓️</div>
<h3>Blockchain Integration</h3>
<p>Immutable records for approvals, documents, and issued licenses</p>
</div>
<div class="card">
<div class="feature-icon">🔄</div>
<h3>Automated Workflows</h3>
<p>Configurable multi-stage approval processes</p>
</div>
<div class="card">
<div class="feature-icon">🔐</div>
<h3>NFT Licenses</h3>
<p>Tamper-proof digital certificates as blockchain tokens</p>
</div>
</div>
</div>
<!-- Slide 4: System Architecture -->
<div class="slide" id="slide4">
<h2>System Architecture</h2>
<div class="architecture-diagram">
<div class="arch-row">
<div class="arch-box frontend">Angular Frontend</div>
</div>
<div class="arch-arrow">↓ ↑</div>
<div class="arch-row">
<div class="arch-box backend">NestJS API Server</div>
</div>
<div class="arch-arrow">↓ ↑</div>
<div class="arch-row">
<div class="arch-box database">PostgreSQL</div>
<div class="arch-box database">Redis</div>
<div class="arch-box database">MinIO</div>
<div class="arch-box blockchain">Hyperledger Besu</div>
</div>
</div>
<div class="grid" style="grid-template-columns: repeat(4, 1fr); gap: 20px;">
<div style="text-align: center;">
<p><strong>PostgreSQL</strong></p>
<p style="font-size: 1rem;">Primary Database</p>
</div>
<div style="text-align: center;">
<p><strong>Redis</strong></p>
<p style="font-size: 1rem;">Caching & Sessions</p>
</div>
<div style="text-align: center;">
<p><strong>MinIO</strong></p>
<p style="font-size: 1rem;">Document Storage</p>
</div>
<div style="text-align: center;">
<p><strong>Besu</strong></p>
<p style="font-size: 1rem;">Blockchain Network</p>
</div>
</div>
</div>
<!-- Slide 5: Technology Stack -->
<div class="slide" id="slide5">
<h2>Technology Stack</h2>
<div class="two-col">
<div>
<h3>Frontend</h3>
<div class="tech-stack">
<span class="tech-badge">Angular 21</span>
<span class="tech-badge">Angular Material</span>
<span class="tech-badge">TailwindCSS</span>
<span class="tech-badge">RxJS</span>
<span class="tech-badge">Playwright</span>
</div>
<h3>Backend</h3>
<div class="tech-stack">
<span class="tech-badge">NestJS</span>
<span class="tech-badge">TypeScript</span>
<span class="tech-badge">Knex ORM</span>
<span class="tech-badge">JWT Auth</span>
<span class="tech-badge">Swagger</span>
</div>
</div>
<div>
<h3>Blockchain</h3>
<div class="tech-stack">
<span class="tech-badge">Hyperledger Besu</span>
<span class="tech-badge">Solidity</span>
<span class="tech-badge">Hardhat</span>
<span class="tech-badge">ethers.js</span>
<span class="tech-badge">Blockscout</span>
</div>
<h3>Infrastructure</h3>
<div class="tech-stack">
<span class="tech-badge">Docker</span>
<span class="tech-badge">PostgreSQL</span>
<span class="tech-badge">Redis</span>
<span class="tech-badge">MinIO</span>
<span class="tech-badge">Nginx</span>
</div>
</div>
</div>
</div>
<!-- Slide 6: Workflow Process -->
<div class="slide" id="slide6">
<h2>License Application Workflow</h2>
<div class="flow-diagram">
<div class="flow-step">
<div class="feature-icon">👤</div>
<p><strong>Citizen</strong></p>
<p style="font-size: 1rem;">Submits Application</p>
</div>
<span class="flow-arrow"></span>
<div class="flow-step">
<div class="feature-icon">📝</div>
<p><strong>Document Upload</strong></p>
<p style="font-size: 1rem;">Hash stored on chain</p>
</div>
<span class="flow-arrow"></span>
<div class="flow-step">
<div class="feature-icon">🏛️</div>
<p><strong>Dept. Review</strong></p>
<p style="font-size: 1rem;">Multi-stage approval</p>
</div>
<span class="flow-arrow"></span>
<div class="flow-step">
<div class="feature-icon"></div>
<p><strong>Approval</strong></p>
<p style="font-size: 1rem;">Recorded on blockchain</p>
</div>
<span class="flow-arrow"></span>
<div class="flow-step">
<div class="feature-icon">🎫</div>
<p><strong>NFT License</strong></p>
<p style="font-size: 1rem;">Issued as token</p>
</div>
</div>
<div class="card" style="margin-top: 40px;">
<h3>Blockchain Records at Each Step</h3>
<ul>
<li>Document hashes stored for integrity verification</li>
<li>Each approval decision recorded immutably</li>
<li>Final license minted as NFT with full audit trail</li>
<li>Real-time status visible to all stakeholders</li>
</ul>
</div>
</div>
<!-- Slide 7: Smart Contracts -->
<div class="slide" id="slide7">
<h2>Blockchain Smart Contracts</h2>
<table>
<tr>
<th>Contract</th>
<th>Purpose</th>
<th>Key Functions</th>
</tr>
<tr>
<td><strong>LicenseNFT</strong></td>
<td>Mint licenses as NFT certificates</td>
<td>mintLicense(), verifyLicense(), revokeLicense()</td>
</tr>
<tr>
<td><strong>DocumentChain</strong></td>
<td>Store document hashes</td>
<td>registerDocument(), verifyDocument()</td>
</tr>
<tr>
<td><strong>ApprovalManager</strong></td>
<td>Record approval decisions</td>
<td>recordApproval(), getApprovalHistory()</td>
</tr>
<tr>
<td><strong>WorkflowRegistry</strong></td>
<td>Manage workflow definitions</td>
<td>registerWorkflow(), getWorkflowStages()</td>
</tr>
</table>
<div class="card" style="margin-top: 30px;">
<h3>Network: Hyperledger Besu (IBFT 2.0)</h3>
<p>Private permissioned network with ~5 second block times and Proof of Authority consensus</p>
</div>
</div>
<!-- Slide 8: User Roles -->
<div class="slide" id="slide8">
<h2>User Roles & Dashboards</h2>
<div class="grid grid-3">
<div class="card">
<div class="feature-icon">👨‍💼</div>
<h3>Administrator</h3>
<ul>
<li>Manage departments</li>
<li>Configure workflows</li>
<li>View audit logs</li>
<li>Platform analytics</li>
<li>User management</li>
</ul>
</div>
<div class="card">
<div class="feature-icon">🏢</div>
<h3>Department</h3>
<ul>
<li>Review applications</li>
<li>Approve/reject requests</li>
<li>Request documents</li>
<li>View assigned queue</li>
<li>Track department KPIs</li>
</ul>
</div>
<div class="card">
<div class="feature-icon">👤</div>
<h3>Citizen</h3>
<ul>
<li>Submit applications</li>
<li>Upload documents</li>
<li>Track status</li>
<li>View timeline</li>
<li>Download licenses</li>
</ul>
</div>
</div>
</div>
<!-- Slide 9: Key Features -->
<div class="slide" id="slide9">
<h2>Key Features</h2>
<div class="grid">
<div class="card">
<h3>Visual Workflow Builder</h3>
<p>Drag-and-drop interface to create multi-stage, multi-department approval workflows</p>
</div>
<div class="card">
<h3>Real-time Blockchain Explorer</h3>
<p>Live view of blocks, transactions, and network health integrated in dashboard</p>
</div>
<div class="card">
<h3>Document Integrity</h3>
<p>SHA-256 hashes stored on blockchain for tamper-proof verification</p>
</div>
<div class="card">
<h3>Comprehensive Audit Trail</h3>
<p>Every action logged with user, timestamp, and correlation IDs</p>
</div>
<div class="card">
<h3>Webhook Notifications</h3>
<p>Real-time event notifications to external systems</p>
</div>
<div class="card">
<h3>API-First Design</h3>
<p>RESTful API with Swagger documentation for integrations</p>
</div>
</div>
</div>
<!-- Slide 10: Security -->
<div class="slide" id="slide10">
<h2>Security & Compliance</h2>
<div class="grid">
<div class="card">
<div class="feature-icon">🔐</div>
<h3>Authentication</h3>
<ul>
<li>JWT-based authentication</li>
<li>Role-based access control</li>
<li>API key auth for departments</li>
<li>Session management with Redis</li>
</ul>
</div>
<div class="card">
<div class="feature-icon">⛓️</div>
<h3>Blockchain Security</h3>
<ul>
<li>Private permissioned network</li>
<li>IBFT 2.0 consensus</li>
<li>Immutable audit trail</li>
<li>Cryptographic verification</li>
</ul>
</div>
<div class="card">
<div class="feature-icon">📊</div>
<h3>Data Protection</h3>
<ul>
<li>Encrypted storage</li>
<li>Secure file handling</li>
<li>Input validation</li>
<li>SQL injection prevention</li>
</ul>
</div>
<div class="card">
<div class="feature-icon">📝</div>
<h3>Audit & Compliance</h3>
<ul>
<li>Complete action logging</li>
<li>Correlation ID tracking</li>
<li>Exportable audit reports</li>
<li>Blockchain verification</li>
</ul>
</div>
</div>
</div>
<!-- Slide 11: Demo Stats -->
<div class="slide" id="slide11">
<h2>Platform Statistics</h2>
<div class="grid" style="grid-template-columns: repeat(3, 1fr);">
<div class="card" style="text-align: center;">
<p style="font-size: 4rem; color: #6366f1; font-weight: 700;">266</p>
<p style="font-size: 1.2rem;">API Tests Passing</p>
</div>
<div class="card" style="text-align: center;">
<p style="font-size: 4rem; color: #10b981; font-weight: 700;">37</p>
<p style="font-size: 1.2rem;">E2E Tests</p>
</div>
<div class="card" style="text-align: center;">
<p style="font-size: 4rem; color: #f59e0b; font-weight: 700;">4</p>
<p style="font-size: 1.2rem;">Smart Contracts</p>
</div>
<div class="card" style="text-align: center;">
<p style="font-size: 4rem; color: #8b5cf6; font-weight: 700;">441</p>
<p style="font-size: 1.2rem;">Source Files</p>
</div>
<div class="card" style="text-align: center;">
<p style="font-size: 4rem; color: #06b6d4; font-weight: 700;">100K+</p>
<p style="font-size: 1.2rem;">Lines of Code</p>
</div>
<div class="card" style="text-align: center;">
<p style="font-size: 4rem; color: #ec4899; font-weight: 700;">9</p>
<p style="font-size: 1.2rem;">Docker Services</p>
</div>
</div>
</div>
<!-- Slide 12: Thank You -->
<div class="slide" id="slide12">
<div class="title-slide" style="flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<h1>Thank You</h1>
<p class="subtitle">Goa GEL Blockchain e-Licensing Platform</p>
<div style="margin-top: 50px;">
<p style="font-size: 1.3rem; color: #64748b;">Demo URLs</p>
<p style="font-size: 1.5rem; margin: 10px 0;">Frontend: <span class="highlight">http://localhost:4200</span></p>
<p style="font-size: 1.5rem; margin: 10px 0;">API Docs: <span class="highlight">http://localhost:3001/api/docs</span></p>
<p style="font-size: 1.5rem; margin: 10px 0;">Blockchain Explorer: <span class="highlight">http://localhost:4000</span></p>
</div>
</div>
</div>
<!-- Navigation -->
<div class="navigation">
<button class="nav-btn" onclick="prevSlide()">← Previous</button>
<button class="nav-btn" onclick="nextSlide()">Next →</button>
</div>
<div class="slide-counter">
<span id="currentSlide">1</span> / <span id="totalSlides">12</span>
</div>
<script>
let currentSlide = 1;
const totalSlides = 12;
document.getElementById('totalSlides').textContent = totalSlides;
function showSlide(n) {
const slides = document.querySelectorAll('.slide');
if (n > totalSlides) currentSlide = 1;
if (n < 1) currentSlide = totalSlides;
slides.forEach(slide => slide.classList.remove('active'));
document.getElementById('slide' + currentSlide).classList.add('active');
document.getElementById('currentSlide').textContent = currentSlide;
}
function nextSlide() {
currentSlide++;
showSlide(currentSlide);
}
function prevSlide() {
currentSlide--;
showSlide(currentSlide);
}
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
nextSlide();
} else if (e.key === 'ArrowLeft') {
prevSlide();
}
});
// Click navigation
document.addEventListener('click', (e) => {
if (e.target.closest('.navigation') || e.target.closest('.nav-btn')) return;
const x = e.clientX;
const width = window.innerWidth;
if (x > width * 0.7) {
nextSlide();
} else if (x < width * 0.3) {
prevSlide();
}
});
</script>
</body>
</html>

View File

@@ -1,99 +0,0 @@
# 🔄 After System Reboot - Start Here
## Session Backups Location
All session backups have been moved to:
```
/Users/Mahi-Workspace/Workspace/Claude/Goa-GEL/session-backups/
```
## Quick Access
### Backend Session (API Test Fixes)
```bash
cd session-backups/backend
cat README_AFTER_REBOOT.md
# Or run automated script:
./COMMANDS_AFTER_REBOOT.sh
```
### Frontend Session
```bash
cd session-backups/frontend
# Frontend agent will store session state here
```
## Why Separate Directories?
- **Backend agent:** Working on API test fixes
- **Frontend agent:** Working on frontend app (parallel work)
- **No conflicts:** Each agent has isolated session storage
- **Shared Docker:** Services are shared, code is isolated
## Structure
```
Goa-GEL/
├── backend/ ← Backend source code
├── frontend/ ← Frontend source code
├── session-backups/
│ ├── backend/ ← Backend session state & commands
│ │ ├── README_AFTER_REBOOT.md
│ │ ├── COMMANDS_AFTER_REBOOT.sh ← Run this!
│ │ ├── SESSION_STATE_BACKUP.md
│ │ └── ... (other backup files)
│ └── frontend/ ← Frontend session state
│ └── (frontend backups will go here)
└── START_HERE_AFTER_REBOOT.md ← This file
```
## Backend Status
-**Progress:** 213/282 tests passing (75.5%)
-**Code:** All fixes completed
- ⏸️ **Waiting:** System reboot to fix Docker
- 🎯 **Expected:** 220+ tests after restart
## What the Backend Script Does
The `session-backups/backend/COMMANDS_AFTER_REBOOT.sh` script will:
1. ✅ Check Docker is running
2. ✅ Restart **only API service** (not frontend)
3. ✅ Verify core services (postgres, redis, minio, api)
4. ✅ Run backend API tests
5. ✅ Save results and show summary
**Frontend work is NOT affected** - only API is restarted.
## After Reboot
1. **Navigate to backend session:**
```bash
cd /Users/Mahi-Workspace/Workspace/Claude/Goa-GEL/session-backups/backend
```
2. **Run the script:**
```bash
./COMMANDS_AFTER_REBOOT.sh
```
3. **Tell Claude:** "tests completed" or "continue"
## Frontend Agent Note
Frontend agent can store session backups in:
```
/Users/Mahi-Workspace/Workspace/Claude/Goa-GEL/session-backups/frontend/
```
This keeps frontend and backend work completely separate and conflict-free.
---
**📂 Go to:** `session-backups/backend/` to resume backend work
**📂 Go to:** `session-backups/frontend/` for frontend work
Both agents can work in parallel without conflicts! 🚀

View File

@@ -50,14 +50,21 @@ COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/package*.json ./ COPY --from=builder --chown=nestjs:nodejs /app/package*.json ./
# Copy compiled database migrations and seeds from dist # Copy compiled database migrations and seeds from dist (only .js files, no .d.ts or .map)
COPY --from=builder --chown=nestjs:nodejs /app/dist/database/migrations ./src/database/migrations COPY --from=builder --chown=nestjs:nodejs /app/dist/database/migrations ./src/database/migrations
COPY --from=builder --chown=nestjs:nodejs /app/dist/database/seeds ./src/database/seeds COPY --from=builder --chown=nestjs:nodejs /app/dist/database/seeds ./src/database/seeds
COPY --from=builder --chown=nestjs:nodejs /app/dist/database/knexfile.js ./src/database/knexfile.js COPY --from=builder --chown=nestjs:nodejs /app/dist/database/knexfile.js ./src/database/knexfile.js
# Copy initialization scripts # Remove TypeScript declaration files that can confuse Knex
RUN find ./src/database -name "*.d.ts" -delete && \
find ./src/database -name "*.js.map" -delete
# Copy initialization scripts (shell and JS)
COPY --chown=nestjs:nodejs scripts ./scripts COPY --chown=nestjs:nodejs scripts ./scripts
RUN chmod +x scripts/*.sh RUN chmod +x scripts/*.sh 2>/dev/null || true
# Create data directory for initialization flags
RUN mkdir -p /app/data && chown nestjs:nodejs /app/data
# Set environment variables # Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production

View File

@@ -22,7 +22,11 @@
"migrate:rollback": "npm run knex -- migrate:rollback", "migrate:rollback": "npm run knex -- migrate:rollback",
"migrate:status": "npm run knex -- migrate:status", "migrate:status": "npm run knex -- migrate:status",
"seed:make": "npm run knex -- seed:make", "seed:make": "npm run knex -- seed:make",
"seed:run": "npm run knex -- seed:run" "seed:run": "npm run knex -- seed:run",
"db:reset": "bash scripts/db-reset.sh",
"db:reset:force": "bash scripts/db-reset.sh --force",
"db:seed:demo": "bash scripts/seed-demo-applications.sh",
"db:health": "bash scripts/health-check.sh"
}, },
"dependencies": { "dependencies": {
"@nestjs/bull": "10.0.1", "@nestjs/bull": "10.0.1",

185
backend/scripts/db-reset.sh Executable file
View File

@@ -0,0 +1,185 @@
#!/bin/bash
set -e
echo "========================================"
echo " Goa-GEL Database Reset"
echo "========================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if running in Docker or locally
if [ -z "$DATABASE_HOST" ]; then
# Load from .env file if not in Docker
if [ -f ".env" ]; then
set -a
source .env
set +a
fi
# Default values for local development
DATABASE_HOST=${DATABASE_HOST:-localhost}
DATABASE_PORT=${DATABASE_PORT:-5432}
DATABASE_NAME=${DATABASE_NAME:-goa_gel_platform}
DATABASE_USER=${DATABASE_USER:-postgres}
DATABASE_PASSWORD=${DATABASE_PASSWORD:-postgres}
fi
echo -e "${YELLOW}WARNING: This will delete ALL data in the database!${NC}"
echo ""
echo "Database: $DATABASE_NAME @ $DATABASE_HOST:$DATABASE_PORT"
echo ""
# Check for --force flag
if [ "$1" != "--force" ] && [ "$1" != "-f" ]; then
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
fi
echo ""
echo "[1/4] Connecting to PostgreSQL..."
# Test connection
if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q' 2>/dev/null; then
echo -e "${RED}ERROR: Cannot connect to PostgreSQL${NC}"
exit 1
fi
echo -e "${GREEN} - Connected successfully${NC}"
echo ""
echo "[2/4] Dropping all tables..."
# Drop all tables by using a transaction
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" <<EOF
-- Disable triggers
SET session_replication_role = replica;
-- Drop all tables in dependency order
DROP TABLE IF EXISTS application_logs CASCADE;
DROP TABLE IF EXISTS blockchain_events CASCADE;
DROP TABLE IF EXISTS blockchain_transactions CASCADE;
DROP TABLE IF EXISTS audit_logs CASCADE;
DROP TABLE IF EXISTS webhook_logs CASCADE;
DROP TABLE IF EXISTS webhooks CASCADE;
DROP TABLE IF EXISTS workflow_states CASCADE;
DROP TABLE IF EXISTS approvals CASCADE;
DROP TABLE IF EXISTS document_versions CASCADE;
DROP TABLE IF EXISTS documents CASCADE;
DROP TABLE IF EXISTS license_requests CASCADE;
DROP TABLE IF EXISTS wallets CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS applicants CASCADE;
DROP TABLE IF EXISTS workflows CASCADE;
DROP TABLE IF EXISTS departments CASCADE;
DROP TABLE IF EXISTS knex_migrations CASCADE;
DROP TABLE IF EXISTS knex_migrations_lock CASCADE;
-- Re-enable triggers
SET session_replication_role = DEFAULT;
-- Vacuum to reclaim space
VACUUM;
EOF
echo -e "${GREEN} - All tables dropped${NC}"
echo ""
echo "[3/4] Running migrations..."
# Determine the correct directory for knexfile
if [ -f "/app/src/database/knexfile.js" ]; then
# Docker environment
cd /app/src/database
elif [ -f "src/database/knexfile.ts" ]; then
# Local development
cd src/database
else
echo -e "${RED}ERROR: Cannot find knexfile${NC}"
exit 1
fi
# Run migrations
node -e "
const knex = require('knex');
const path = require('path');
// Try to load compiled JS first, then TypeScript
let config;
try {
config = require('./knexfile.js').default || require('./knexfile.js');
} catch (e) {
require('ts-node/register');
config = require('./knexfile.ts').default || require('./knexfile.ts');
}
const env = process.env.NODE_ENV || 'production';
const db = knex(config[env] || config.development);
db.migrate.latest()
.then(([batchNo, migrations]) => {
console.log(' - Applied ' + migrations.length + ' migration(s)');
migrations.forEach(m => console.log(' * ' + m));
process.exit(0);
})
.catch(err => {
console.error(' - Migration failed:', err.message);
process.exit(1);
})
.finally(() => db.destroy());
"
echo -e "${GREEN} - Migrations completed${NC}"
echo ""
echo "[4/4] Running seeds..."
node -e "
const knex = require('knex');
// Try to load compiled JS first, then TypeScript
let config;
try {
config = require('./knexfile.js').default || require('./knexfile.js');
} catch (e) {
require('ts-node/register');
config = require('./knexfile.ts').default || require('./knexfile.ts');
}
const env = process.env.NODE_ENV || 'production';
const db = knex(config[env] || config.development);
db.seed.run()
.then(() => {
console.log(' - Seeds completed successfully');
process.exit(0);
})
.catch(err => {
console.error(' - Seed failed:', err.message);
process.exit(1);
})
.finally(() => db.destroy());
"
echo -e "${GREEN} - Seeds completed${NC}"
echo ""
echo "========================================"
echo -e "${GREEN} Database Reset Complete!${NC}"
echo "========================================"
echo ""
echo "Demo Accounts:"
echo " Admin: admin@goa.gov.in / Admin@123"
echo " Fire Dept: fire@goa.gov.in / Fire@123"
echo " Tourism: tourism@goa.gov.in / Tourism@123"
echo " Municipality: municipality@goa.gov.in / Municipality@123"
echo " Citizen 1: citizen@example.com / Citizen@123"
echo " Citizen 2: citizen2@example.com / Citizen@123"
echo ""

184
backend/scripts/docker-entrypoint.sh Normal file → Executable file
View File

@@ -1,7 +1,10 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "🚀 Starting Goa-GEL Backend Initialization..." echo "========================================"
echo " Goa-GEL Backend Initialization"
echo "========================================"
echo ""
# Function to check if this is first boot # Function to check if this is first boot
is_first_boot() { is_first_boot() {
@@ -12,34 +15,191 @@ is_first_boot() {
fi fi
} }
# Function to check if database has data
db_has_data() {
local count
count=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "0")
if [ "$count" -gt "0" ]; then
return 0 # true - has data
else
return 1 # false - empty
fi
}
# Function to check if migrations table exists
migrations_table_exists() {
local exists
exists=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'knex_migrations');" 2>/dev/null || echo "f")
if [ "$exists" = "t" ]; then
return 0 # true
else
return 1 # false
fi
}
# Ensure data directory exists # Ensure data directory exists
mkdir -p /app/data mkdir -p /app/data
# Ensure .env file exists # Ensure .env file exists
touch /app/.env touch /app/.env
# 1. Wait for and initialize database # ========================================
echo "📊 Step 1: Database initialization..." # Step 1: Wait for PostgreSQL
chmod +x /app/scripts/init-db.sh # ========================================
/app/scripts/init-db.sh echo "[1/4] Waiting for PostgreSQL to be ready..."
retries=30
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q' 2>/dev/null; do
retries=$((retries - 1))
if [ $retries -le 0 ]; then
echo "ERROR: PostgreSQL is not available after 30 retries"
exit 1
fi
echo " - PostgreSQL is unavailable - sleeping (${retries} retries left)"
sleep 2
done
echo " - PostgreSQL is up and accepting connections"
# ========================================
# Step 2: Run Database Migrations
# ========================================
echo ""
echo "[2/4] Running database migrations..."
# Change to database directory for Knex
cd /app/src/database
# Run migrations using the compiled knexfile
if node -e "
const knex = require('knex');
const config = require('./knexfile.js').default || require('./knexfile.js');
const env = process.env.NODE_ENV || 'production';
const db = knex(config[env]);
db.migrate.latest()
.then(([batchNo, migrations]) => {
if (migrations.length === 0) {
console.log(' - All migrations already applied');
} else {
console.log(' - Applied ' + migrations.length + ' migration(s)');
migrations.forEach(m => console.log(' * ' + m));
}
process.exit(0);
})
.catch(err => {
console.error(' - Migration failed:', err.message);
process.exit(1);
})
.finally(() => db.destroy());
"; then
echo " - Migrations completed successfully"
else
echo "ERROR: Migration failed"
exit 1
fi
# ========================================
# Step 3: Run Database Seeds (if needed)
# ========================================
echo ""
echo "[3/4] Checking if database seeding is needed..."
cd /app/src/database
# Check if we should run seeds
SHOULD_SEED="false"
if ! db_has_data; then
echo " - Database is empty, seeding required"
SHOULD_SEED="true"
elif [ "$FORCE_RESEED" = "true" ]; then
echo " - FORCE_RESEED is set, reseeding database"
SHOULD_SEED="true"
else
echo " - Database already has data, skipping seed"
fi
if [ "$SHOULD_SEED" = "true" ]; then
echo " - Running database seeds..."
if node -e "
const knex = require('knex');
const config = require('./knexfile.js').default || require('./knexfile.js');
const env = process.env.NODE_ENV || 'production';
const db = knex(config[env]);
db.seed.run()
.then(() => {
console.log(' - Seeds completed successfully');
process.exit(0);
})
.catch(err => {
console.error(' - Seed failed:', err.message);
process.exit(1);
})
.finally(() => db.destroy());
"; then
echo " - Database seeded successfully"
else
echo "ERROR: Seeding failed"
exit 1
fi
fi
# ========================================
# Step 4: Initialize Blockchain (if needed)
# ========================================
echo ""
echo "[4/4] Checking blockchain initialization..."
cd /app
# 2. Initialize blockchain (only on first boot or if not configured)
if is_first_boot || [ -z "$CONTRACT_ADDRESS_LICENSE_NFT" ] || [ "$CONTRACT_ADDRESS_LICENSE_NFT" = "0x0000000000000000000000000000000000000000" ]; then if is_first_boot || [ -z "$CONTRACT_ADDRESS_LICENSE_NFT" ] || [ "$CONTRACT_ADDRESS_LICENSE_NFT" = "0x0000000000000000000000000000000000000000" ]; then
echo "🔗 Step 2: Blockchain initialization..." echo " - Blockchain not initialized, deploying contracts..."
node /app/scripts/init-blockchain.js node /app/scripts/init-blockchain.js
# Mark as initialized # Mark as initialized
touch /app/data/.initialized touch /app/data/.initialized
echo " Blockchain initialization complete!" echo " - Blockchain initialization complete"
# Reload environment variables # Reload environment variables
if [ -f "/app/.env" ]; then if [ -f "/app/.env" ]; then
export $(grep -v '^#' /app/.env | xargs) set -a
source /app/.env
set +a
fi fi
else else
echo "⏭️ Step 2: Blockchain already initialized" echo " - Blockchain already initialized, skipping"
fi fi
# 3. Start the application # ========================================
echo "🎯 Step 3: Starting NestJS application..." # Final Health Check
# ========================================
echo ""
echo "========================================"
echo " Performing Health Checks"
echo "========================================"
# Verify database has expected data
USER_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "0")
DEPT_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM departments;" 2>/dev/null || echo "0")
WORKFLOW_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM workflows;" 2>/dev/null || echo "0")
echo " - Users: $USER_COUNT"
echo " - Departments: $DEPT_COUNT"
echo " - Workflows: $WORKFLOW_COUNT"
if [ "$USER_COUNT" -eq "0" ] || [ "$DEPT_COUNT" -eq "0" ]; then
echo ""
echo "WARNING: Database appears to be empty!"
echo "You may need to run: docker compose exec api npm run db:reset"
fi
# ========================================
# Start Application
# ========================================
echo ""
echo "========================================"
echo " Starting NestJS Application"
echo "========================================"
echo ""
exec npm run start:prod exec npm run start:prod

20
backend/scripts/health-check.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Health check script for Docker container
# Returns exit code 0 if healthy, 1 if unhealthy
# Check if API is responding
if ! wget --spider -q http://localhost:3001/api/v1/health 2>/dev/null; then
echo "API health endpoint not responding"
exit 1
fi
# Check database connection and data
USER_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "0")
if [ "$USER_COUNT" -eq "0" ]; then
echo "Database has no users"
exit 1
fi
echo "Health check passed: API responding, $USER_COUNT users in database"
exit 0

View File

@@ -140,6 +140,7 @@ async function deployPlaceholderContract(wallet, name) {
/** /**
* Update .env file with generated values * Update .env file with generated values
* Values containing spaces are quoted for shell compatibility
*/ */
function updateEnvFile(values) { function updateEnvFile(values) {
const envPath = path.join(__dirname, '../.env'); const envPath = path.join(__dirname, '../.env');
@@ -151,11 +152,13 @@ function updateEnvFile(values) {
// Update or add each value // Update or add each value
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
// Quote values that contain spaces (like mnemonic phrases)
const formattedValue = value.includes(' ') ? `"${value}"` : value;
const regex = new RegExp(`^${key}=.*$`, 'm'); const regex = new RegExp(`^${key}=.*$`, 'm');
if (regex.test(envContent)) { if (regex.test(envContent)) {
envContent = envContent.replace(regex, `${key}=${value}`); envContent = envContent.replace(regex, `${key}=${formattedValue}`);
} else { } else {
envContent += `\n${key}=${value}`; envContent += `\n${key}=${formattedValue}`;
} }
} }

32
backend/scripts/init-db.sh Normal file → Executable file
View File

@@ -1,30 +1,16 @@
#!/bin/bash #!/bin/bash
# This script is now deprecated - database initialization is handled by docker-entrypoint.sh
# Kept for backward compatibility
set -e set -e
echo "🔄 Waiting for database to be ready..." echo "Database initialization is handled by docker-entrypoint.sh"
echo "This script will only wait for PostgreSQL to be ready..."
# Wait for database to be ready
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q' 2>/dev/null; do until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q' 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping" echo "PostgreSQL is unavailable - sleeping"
sleep 2 sleep 2
done done
echo "PostgreSQL is up - checking if database is initialized..." echo "PostgreSQL is up and accepting connections"
# Check if users table exists (indicating database is already set up)
TABLE_EXISTS=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users');" 2>/dev/null || echo "f")
if [ "$TABLE_EXISTS" = "t" ]; then
echo "✅ Database already initialized, skipping setup."
else
echo "📦 First time setup - creating tables and seeding data..."
# Run the SQL scripts directly
echo "Creating tables..."
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -f /app/scripts/create-all-tables.sql
echo "🌱 Seeding initial data..."
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -f /app/scripts/seed-initial-data.sql
echo "✅ Database initialized successfully!"
fi
echo "✅ Database ready!"

View File

@@ -0,0 +1,214 @@
#!/bin/bash
set -e
echo "========================================"
echo " Seed Demo License Applications"
echo "========================================"
echo ""
# Check if running in Docker or locally
if [ -z "$DATABASE_HOST" ]; then
# Load from .env file if not in Docker
if [ -f ".env" ]; then
set -a
source .env
set +a
fi
DATABASE_HOST=${DATABASE_HOST:-localhost}
DATABASE_PORT=${DATABASE_PORT:-5432}
DATABASE_NAME=${DATABASE_NAME:-goa_gel_platform}
DATABASE_USER=${DATABASE_USER:-postgres}
DATABASE_PASSWORD=${DATABASE_PASSWORD:-postgres}
fi
echo "Adding demo license applications..."
# Get citizen ID
CITIZEN_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM applicants WHERE digilocker_id = 'DL-GOA-CITIZEN-001' LIMIT 1;" 2>/dev/null)
if [ -z "$CITIZEN_ID" ] || [ "$CITIZEN_ID" = "" ]; then
echo "ERROR: Could not find citizen applicant. Run seeds first."
exit 1
fi
CITIZEN_ID=$(echo $CITIZEN_ID | xargs) # Trim whitespace
# Get workflow ID
WORKFLOW_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM workflows WHERE workflow_type = 'RESORT_LICENSE' LIMIT 1;" 2>/dev/null)
WORKFLOW_ID=$(echo $WORKFLOW_ID | xargs)
# Get department IDs
FIRE_DEPT_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM departments WHERE code = 'FIRE_DEPT' LIMIT 1;" 2>/dev/null)
FIRE_DEPT_ID=$(echo $FIRE_DEPT_ID | xargs)
TOURISM_DEPT_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM departments WHERE code = 'TOURISM_DEPT' LIMIT 1;" 2>/dev/null)
TOURISM_DEPT_ID=$(echo $TOURISM_DEPT_ID | xargs)
echo " - Found applicant: $CITIZEN_ID"
echo " - Found workflow: $WORKFLOW_ID"
# Insert demo license applications
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" <<EOF
-- Demo Application 1: Draft status
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, created_at, updated_at)
VALUES (
'demo1111-1111-1111-1111-111111111111',
'GOA-2024-RESORT-001',
'$CITIZEN_ID',
'RESORT_LICENSE',
'$WORKFLOW_ID',
'DRAFT',
'{"businessName": "Paradise Beach Resort", "location": "Calangute Beach", "type": "Beach Resort", "capacity": 50}',
NULL,
CURRENT_TIMESTAMP - INTERVAL '5 days',
CURRENT_TIMESTAMP - INTERVAL '5 days'
) ON CONFLICT (request_number) DO NOTHING;
-- Demo Application 2: Pending - waiting for Fire Dept
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, created_at, updated_at)
VALUES (
'demo2222-2222-2222-2222-222222222222',
'GOA-2024-RESORT-002',
'$CITIZEN_ID',
'RESORT_LICENSE',
'$WORKFLOW_ID',
'PENDING',
'{"businessName": "Sunset View Resort", "location": "Baga Beach", "type": "Boutique Resort", "capacity": 30}',
'stage_1_fire',
CURRENT_TIMESTAMP - INTERVAL '3 days',
CURRENT_TIMESTAMP - INTERVAL '4 days',
CURRENT_TIMESTAMP - INTERVAL '3 days'
) ON CONFLICT (request_number) DO NOTHING;
-- Create pending approval for Application 2
INSERT INTO approvals (id, request_id, department_id, status, created_at, updated_at)
SELECT
'appr2222-2222-2222-2222-222222222222',
'demo2222-2222-2222-2222-222222222222',
'$FIRE_DEPT_ID',
'PENDING',
CURRENT_TIMESTAMP - INTERVAL '3 days',
CURRENT_TIMESTAMP - INTERVAL '3 days'
WHERE NOT EXISTS (
SELECT 1 FROM approvals WHERE id = 'appr2222-2222-2222-2222-222222222222'
);
-- Create workflow state for Application 2
INSERT INTO workflow_states (id, request_id, current_stage_id, completed_stages, pending_approvals, execution_log, stage_started_at, created_at, updated_at)
SELECT
'wfst2222-2222-2222-2222-222222222222',
'demo2222-2222-2222-2222-222222222222',
'stage_1_fire',
'[]',
'["$FIRE_DEPT_ID"]',
'[{"action": "SUBMITTED", "timestamp": "' || (CURRENT_TIMESTAMP - INTERVAL '3 days')::text || '"}]',
CURRENT_TIMESTAMP - INTERVAL '3 days',
CURRENT_TIMESTAMP - INTERVAL '3 days',
CURRENT_TIMESTAMP - INTERVAL '3 days'
WHERE NOT EXISTS (
SELECT 1 FROM workflow_states WHERE request_id = 'demo2222-2222-2222-2222-222222222222'
);
-- Demo Application 3: In Review - Fire approved, Tourism/Municipality pending
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, created_at, updated_at)
VALUES (
'demo3333-3333-3333-3333-333333333333',
'GOA-2024-RESORT-003',
'$CITIZEN_ID',
'RESORT_LICENSE',
'$WORKFLOW_ID',
'IN_REVIEW',
'{"businessName": "Ocean Breeze Resort", "location": "Anjuna Beach", "type": "Eco Resort", "capacity": 25}',
'stage_2_parallel',
CURRENT_TIMESTAMP - INTERVAL '10 days',
CURRENT_TIMESTAMP - INTERVAL '12 days',
CURRENT_TIMESTAMP - INTERVAL '2 days'
) ON CONFLICT (request_number) DO NOTHING;
-- Fire approval for Application 3 (completed)
INSERT INTO approvals (id, request_id, department_id, status, remarks, created_at, updated_at)
SELECT
'appr3333-fire-3333-3333-333333333333',
'demo3333-3333-3333-3333-333333333333',
'$FIRE_DEPT_ID',
'APPROVED',
'Fire safety requirements met. All exits properly marked.',
CURRENT_TIMESTAMP - INTERVAL '7 days',
CURRENT_TIMESTAMP - INTERVAL '5 days'
WHERE NOT EXISTS (
SELECT 1 FROM approvals WHERE id = 'appr3333-fire-3333-3333-333333333333'
);
-- Tourism pending approval for Application 3
INSERT INTO approvals (id, request_id, department_id, status, created_at, updated_at)
SELECT
'appr3333-tour-3333-3333-333333333333',
'demo3333-3333-3333-3333-333333333333',
'$TOURISM_DEPT_ID',
'PENDING',
CURRENT_TIMESTAMP - INTERVAL '5 days',
CURRENT_TIMESTAMP - INTERVAL '5 days'
WHERE NOT EXISTS (
SELECT 1 FROM approvals WHERE id = 'appr3333-tour-3333-3333-333333333333'
);
-- Demo Application 4: Approved (completed all stages)
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, approved_at, created_at, updated_at)
VALUES (
'demo4444-4444-4444-4444-444444444444',
'GOA-2024-RESORT-004',
'$CITIZEN_ID',
'RESORT_LICENSE',
'$WORKFLOW_ID',
'APPROVED',
'{"businessName": "Golden Sands Resort", "location": "Candolim Beach", "type": "Luxury Resort", "capacity": 100}',
NULL,
CURRENT_TIMESTAMP - INTERVAL '30 days',
CURRENT_TIMESTAMP - INTERVAL '5 days',
CURRENT_TIMESTAMP - INTERVAL '35 days',
CURRENT_TIMESTAMP - INTERVAL '5 days'
) ON CONFLICT (request_number) DO NOTHING;
-- Demo Application 5: Rejected
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, created_at, updated_at)
VALUES (
'demo5555-5555-5555-5555-555555555555',
'GOA-2024-RESORT-005',
'$CITIZEN_ID',
'RESORT_LICENSE',
'$WORKFLOW_ID',
'REJECTED',
'{"businessName": "Beach Shack Resort", "location": "Morjim Beach", "type": "Beach Shack", "capacity": 15}',
'stage_1_fire',
CURRENT_TIMESTAMP - INTERVAL '20 days',
CURRENT_TIMESTAMP - INTERVAL '25 days',
CURRENT_TIMESTAMP - INTERVAL '15 days'
) ON CONFLICT (request_number) DO NOTHING;
-- Rejected approval for Application 5
INSERT INTO approvals (id, request_id, department_id, status, remarks, created_at, updated_at)
SELECT
'appr5555-fire-5555-5555-555555555555',
'demo5555-5555-5555-5555-555555555555',
'$FIRE_DEPT_ID',
'REJECTED',
'Fire safety requirements not met. Insufficient emergency exits.',
CURRENT_TIMESTAMP - INTERVAL '18 days',
CURRENT_TIMESTAMP - INTERVAL '15 days'
WHERE NOT EXISTS (
SELECT 1 FROM approvals WHERE id = 'appr5555-fire-5555-5555-555555555555'
);
EOF
echo ""
echo "Demo applications created:"
echo " - GOA-2024-RESORT-001: Draft"
echo " - GOA-2024-RESORT-002: Pending (Fire Dept review)"
echo " - GOA-2024-RESORT-003: In Review (Tourism/Municipality pending)"
echo " - GOA-2024-RESORT-004: Approved"
echo " - GOA-2024-RESORT-005: Rejected"
echo ""
echo "Done!"

View File

@@ -39,7 +39,15 @@ import { UsersModule } from './modules/users/users.module';
// Configuration // Configuration
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [appConfig, databaseConfig, blockchainConfig, storageConfig, redisConfig, jwtConfig, minioConfig], load: [
appConfig,
databaseConfig,
blockchainConfig,
storageConfig,
redisConfig,
jwtConfig,
minioConfig,
],
validationSchema: appConfigValidationSchema, validationSchema: appConfigValidationSchema,
validationOptions: { validationOptions: {
abortEarly: false, abortEarly: false,
@@ -57,10 +65,12 @@ import { UsersModule } from './modules/users/users.module';
const nodeEnv = configService.get<string>('NODE_ENV', 'development'); const nodeEnv = configService.get<string>('NODE_ENV', 'development');
const isDevelopment = nodeEnv === 'development' || nodeEnv === 'test'; const isDevelopment = nodeEnv === 'development' || nodeEnv === 'test';
return [{ return [
ttl: isDevelopment ? 1000 : configService.get<number>('RATE_LIMIT_TTL', 60) * 1000, {
limit: isDevelopment ? 10000 : configService.get<number>('RATE_LIMIT_GLOBAL', 100), ttl: isDevelopment ? 1000 : configService.get<number>('RATE_LIMIT_TTL', 60) * 1000,
}]; limit: isDevelopment ? 10000 : configService.get<number>('RATE_LIMIT_GLOBAL', 100),
},
];
}, },
}), }),

View File

@@ -8,12 +8,7 @@ export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 100; export const MAX_PAGE_SIZE = 100;
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export const ALLOWED_MIME_TYPES = [ export const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
'application/pdf',
'image/jpeg',
'image/png',
'image/jpg',
];
export const REQUEST_NUMBER_PREFIX = { export const REQUEST_NUMBER_PREFIX = {
RESORT_LICENSE: 'RL', RESORT_LICENSE: 'RL',

View File

@@ -1,9 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export const CorrelationId = createParamDecorator( export const CorrelationId = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
(data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest();
const request = ctx.switchToHttp().getRequest(); return request.headers['x-correlation-id'] || uuidv4();
return request.headers['x-correlation-id'] || uuidv4(); });
},
);

View File

@@ -1,10 +1,4 @@
import { import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { ERROR_CODES } from '@common/constants/error-codes'; import { ERROR_CODES } from '@common/constants/error-codes';
@@ -34,10 +28,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
let message = 'An unexpected error occurred'; let message = 'An unexpected error occurred';
if (exception instanceof Error) { if (exception instanceof Error) {
this.logger.error( this.logger.error(`Unhandled Exception: ${exception.message}`, exception.stack);
`Unhandled Exception: ${exception.message}`,
exception.stack,
);
if (exception.message.includes('ECONNREFUSED')) { if (exception.message.includes('ECONNREFUSED')) {
status = HttpStatus.SERVICE_UNAVAILABLE; status = HttpStatus.SERVICE_UNAVAILABLE;

View File

@@ -49,11 +49,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
} }
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
message = exception.message; message = exception.message;
this.logger.error( this.logger.error(`Unhandled exception: ${message}`, exception.stack, correlationId);
`Unhandled exception: ${message}`,
exception.stack,
correlationId,
);
} }
const errorResponse: ErrorResponse = { const errorResponse: ErrorResponse = {

View File

@@ -1,9 +1,4 @@
import { import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import { API_KEY_HEADER, DEPARTMENT_CODE_HEADER, ERROR_CODES } from '../constants'; import { API_KEY_HEADER, DEPARTMENT_CODE_HEADER, ERROR_CODES } from '../constants';

View File

@@ -1,9 +1,4 @@
import { import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { UserRole } from '../enums'; import { UserRole } from '../enums';
import { ERROR_CODES } from '../constants'; import { ERROR_CODES } from '../constants';
@@ -34,7 +29,9 @@ export class RolesGuard implements CanActivate {
}); });
} }
const hasRole = requiredRoles.some((role) => user.role === role); // Map CITIZEN role to APPLICANT for backwards compatibility
const normalizedUserRole = user.role === 'CITIZEN' ? UserRole.APPLICANT : user.role;
const hasRole = requiredRoles.some(role => normalizedUserRole === role);
if (!hasRole) { if (!hasRole) {
throw new ForbiddenException({ throw new ForbiddenException({

View File

@@ -1,9 +1,4 @@
import { import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';

View File

@@ -1,10 +1,4 @@
import { import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
@@ -17,7 +11,7 @@ export class LoggingInterceptor implements NestInterceptor {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>(); const response = context.switchToHttp().getResponse<Response>();
const { method, url, ip } = request; const { method, url, ip } = request;
const correlationId = request.headers['x-correlation-id'] as string || 'no-correlation-id'; const correlationId = (request.headers['x-correlation-id'] as string) || 'no-correlation-id';
const userAgent = request.get('user-agent') || ''; const userAgent = request.get('user-agent') || '';
const startTime = Date.now(); const startTime = Date.now();

View File

@@ -11,8 +11,6 @@ import { timeout } from 'rxjs/operators';
@Injectable() @Injectable()
export class TimeoutInterceptor implements NestInterceptor { export class TimeoutInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe( return next.handle().pipe(timeout(30000));
timeout(30000),
);
} }
} }

View File

@@ -1,9 +1,4 @@
import { import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';

View File

@@ -1,9 +1,4 @@
import { import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { ERROR_CODES } from '../constants'; import { ERROR_CODES } from '../constants';
@@ -19,7 +14,7 @@ export class CustomValidationPipe implements PipeTransform {
const errors = await validate(object); const errors = await validate(object);
if (errors.length > 0) { if (errors.length > 0) {
const messages = errors.map((error) => { const messages = errors.map(error => {
const constraints = error.constraints || {}; const constraints = error.constraints || {};
return { return {
field: error.property, field: error.property,

View File

@@ -15,10 +15,7 @@ export async function generateApiKey(): Promise<{
const apiKey = `goa_${crypto.randomBytes(16).toString('hex')}`; const apiKey = `goa_${crypto.randomBytes(16).toString('hex')}`;
const apiSecret = crypto.randomBytes(32).toString('hex'); const apiSecret = crypto.randomBytes(32).toString('hex');
const [apiKeyHash, apiSecretHash] = await Promise.all([ const [apiKeyHash, apiSecretHash] = await Promise.all([hash(apiKey), hash(apiSecret)]);
hash(apiKey),
hash(apiSecret),
]);
return { return {
apiKey, apiKey,
@@ -40,10 +37,7 @@ export class CryptoUtil {
const iv = randomBytes(CryptoUtil.IV_LENGTH); const iv = randomBytes(CryptoUtil.IV_LENGTH);
const cipher = createCipheriv(CryptoUtil.ALGORITHM, key, iv); const cipher = createCipheriv(CryptoUtil.ALGORITHM, key, iv);
const encrypted = Buffer.concat([ const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
cipher.update(data, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag(); const authTag = cipher.getAuthTag();

View File

@@ -85,10 +85,7 @@ export class DateUtil {
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear(); const year = date.getFullYear();
return format return format.replace('DD', day).replace('MM', month).replace('YYYY', year.toString());
.replace('DD', day)
.replace('MM', month)
.replace('YYYY', year.toString());
} }
static parseISO(dateString: string): Date { static parseISO(dateString: string): Date {

View File

@@ -1,7 +1,7 @@
import { QueryBuilder } from 'objection'; import { QueryBuilder } from 'objection';
export interface PaginatedResult<T> { export interface PaginatedResult<T> {
results: T[]; data: T[];
total: number; total: number;
} }
@@ -19,7 +19,7 @@ export async function paginate<T>(
const l = limit > 0 ? limit : 10; const l = limit > 0 ? limit : 10;
const { results, total } = await query.page(p, l); const { results, total } = await query.page(p, l);
return { results, total }; return { data: results, total };
} }
export { QueryBuilder }; export { QueryBuilder };

View File

@@ -16,7 +16,9 @@ export default registerAs('minio', (): MinioConfig => {
const secretKey = process.env.MINIO_SECRET_KEY || 'minioadmin_secret_change_this'; const secretKey = process.env.MINIO_SECRET_KEY || 'minioadmin_secret_change_this';
if (accessKey === 'minioadmin' || secretKey === 'minioadmin_secret_change_this') { if (accessKey === 'minioadmin' || secretKey === 'minioadmin_secret_change_this') {
console.warn('Warning: MinIO credentials are using default values. Change these in production!'); console.warn(
'Warning: MinIO credentials are using default values. Change these in production!',
);
} }
return { return {

View File

@@ -22,9 +22,7 @@ export const KNEX_CONNECTION = 'KNEX_CONNECTION';
database: configService.get<string>('database.database'), database: configService.get<string>('database.database'),
user: configService.get<string>('database.username'), user: configService.get<string>('database.username'),
password: configService.get<string>('database.password'), password: configService.get<string>('database.password'),
ssl: configService.get<boolean>('database.ssl') ssl: configService.get<boolean>('database.ssl') ? { rejectUnauthorized: false } : false,
? { rejectUnauthorized: false }
: false,
}, },
pool: { pool: {
min: 2, min: 2,
@@ -43,7 +41,7 @@ export const KNEX_CONNECTION = 'KNEX_CONNECTION';
exports: [KNEX_CONNECTION, ModelsModule], exports: [KNEX_CONNECTION, ModelsModule],
}) })
export class DatabaseModule implements OnModuleDestroy { export class DatabaseModule implements OnModuleDestroy {
constructor(@Inject(KNEX_CONNECTION) private readonly knex: Knex.Knex) { } constructor(@Inject(KNEX_CONNECTION) private readonly knex: Knex.Knex) {}
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
if (this.knex) { if (this.knex) {

View File

@@ -5,7 +5,7 @@ export async function up(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
// Applicants table // Applicants table
await knex.schema.createTable('applicants', (table) => { await knex.schema.createTable('applicants', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('digilocker_id', 255).notNullable().unique(); table.string('digilocker_id', 255).notNullable().unique();
table.string('name', 255).notNullable(); table.string('name', 255).notNullable();
@@ -21,7 +21,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Departments table // Departments table
await knex.schema.createTable('departments', (table) => { await knex.schema.createTable('departments', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('code', 50).notNullable().unique(); table.string('code', 50).notNullable().unique();
table.string('name', 255).notNullable(); table.string('name', 255).notNullable();
@@ -39,7 +39,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Workflows table // Workflows table
await knex.schema.createTable('workflows', (table) => { await knex.schema.createTable('workflows', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('workflow_type', 100).notNullable().unique(); table.string('workflow_type', 100).notNullable().unique();
table.string('name', 255).notNullable(); table.string('name', 255).notNullable();
@@ -56,11 +56,16 @@ export async function up(knex: Knex): Promise<void> {
}); });
// License Requests table // License Requests table
await knex.schema.createTable('license_requests', (table) => { await knex.schema.createTable('license_requests', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('request_number', 50).notNullable().unique(); table.string('request_number', 50).notNullable().unique();
table.bigInteger('token_id'); table.bigInteger('token_id');
table.uuid('applicant_id').notNullable().references('id').inTable('applicants').onDelete('CASCADE'); table
.uuid('applicant_id')
.notNullable()
.references('id')
.inTable('applicants')
.onDelete('CASCADE');
table.string('request_type', 100).notNullable(); table.string('request_type', 100).notNullable();
table.uuid('workflow_id').references('id').inTable('workflows').onDelete('SET NULL'); table.uuid('workflow_id').references('id').inTable('workflows').onDelete('SET NULL');
table.string('status', 50).notNullable().defaultTo('DRAFT'); table.string('status', 50).notNullable().defaultTo('DRAFT');
@@ -81,9 +86,14 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Documents table // Documents table
await knex.schema.createTable('documents', (table) => { await knex.schema.createTable('documents', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.uuid('request_id').notNullable().references('id').inTable('license_requests').onDelete('CASCADE'); table
.uuid('request_id')
.notNullable()
.references('id')
.inTable('license_requests')
.onDelete('CASCADE');
table.string('doc_type', 100).notNullable(); table.string('doc_type', 100).notNullable();
table.string('original_filename', 255).notNullable(); table.string('original_filename', 255).notNullable();
table.integer('current_version').notNullable().defaultTo(1); table.integer('current_version').notNullable().defaultTo(1);
@@ -98,9 +108,14 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Document Versions table // Document Versions table
await knex.schema.createTable('document_versions', (table) => { await knex.schema.createTable('document_versions', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.uuid('document_id').notNullable().references('id').inTable('documents').onDelete('CASCADE'); table
.uuid('document_id')
.notNullable()
.references('id')
.inTable('documents')
.onDelete('CASCADE');
table.integer('version').notNullable(); table.integer('version').notNullable();
table.string('hash', 66).notNullable(); table.string('hash', 66).notNullable();
table.string('minio_path', 500).notNullable(); table.string('minio_path', 500).notNullable();
@@ -115,10 +130,20 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Approvals table // Approvals table
await knex.schema.createTable('approvals', (table) => { await knex.schema.createTable('approvals', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.uuid('request_id').notNullable().references('id').inTable('license_requests').onDelete('CASCADE'); table
table.uuid('department_id').notNullable().references('id').inTable('departments').onDelete('CASCADE'); .uuid('request_id')
.notNullable()
.references('id')
.inTable('license_requests')
.onDelete('CASCADE');
table
.uuid('department_id')
.notNullable()
.references('id')
.inTable('departments')
.onDelete('CASCADE');
table.string('status', 50).notNullable().defaultTo('PENDING'); table.string('status', 50).notNullable().defaultTo('PENDING');
table.text('remarks'); table.text('remarks');
table.string('remarks_hash', 66); table.string('remarks_hash', 66);
@@ -137,9 +162,15 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Workflow States table // Workflow States table
await knex.schema.createTable('workflow_states', (table) => { await knex.schema.createTable('workflow_states', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.uuid('request_id').notNullable().unique().references('id').inTable('license_requests').onDelete('CASCADE'); table
.uuid('request_id')
.notNullable()
.unique()
.references('id')
.inTable('license_requests')
.onDelete('CASCADE');
table.string('current_stage_id', 100).notNullable(); table.string('current_stage_id', 100).notNullable();
table.jsonb('completed_stages').notNullable().defaultTo('[]'); table.jsonb('completed_stages').notNullable().defaultTo('[]');
table.jsonb('pending_approvals').notNullable().defaultTo('[]'); table.jsonb('pending_approvals').notNullable().defaultTo('[]');
@@ -152,9 +183,14 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Webhooks table // Webhooks table
await knex.schema.createTable('webhooks', (table) => { await knex.schema.createTable('webhooks', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.uuid('department_id').notNullable().references('id').inTable('departments').onDelete('CASCADE'); table
.uuid('department_id')
.notNullable()
.references('id')
.inTable('departments')
.onDelete('CASCADE');
table.string('url', 500).notNullable(); table.string('url', 500).notNullable();
table.jsonb('events').notNullable(); table.jsonb('events').notNullable();
table.string('secret_hash', 255).notNullable(); table.string('secret_hash', 255).notNullable();
@@ -166,7 +202,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Webhook Logs table // Webhook Logs table
await knex.schema.createTable('webhook_logs', (table) => { await knex.schema.createTable('webhook_logs', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.uuid('webhook_id').notNullable().references('id').inTable('webhooks').onDelete('CASCADE'); table.uuid('webhook_id').notNullable().references('id').inTable('webhooks').onDelete('CASCADE');
table.string('event_type', 100).notNullable(); table.string('event_type', 100).notNullable();
@@ -185,7 +221,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Audit Logs table // Audit Logs table
await knex.schema.createTable('audit_logs', (table) => { await knex.schema.createTable('audit_logs', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('entity_type', 50).notNullable(); table.string('entity_type', 50).notNullable();
table.uuid('entity_id').notNullable(); table.uuid('entity_id').notNullable();
@@ -207,7 +243,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Blockchain Transactions table // Blockchain Transactions table
await knex.schema.createTable('blockchain_transactions', (table) => { await knex.schema.createTable('blockchain_transactions', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('tx_hash', 66).notNullable().unique(); table.string('tx_hash', 66).notNullable().unique();
table.string('tx_type', 50).notNullable(); table.string('tx_type', 50).notNullable();

View File

@@ -2,7 +2,7 @@ import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
// Users table for email/password authentication // Users table for email/password authentication
await knex.schema.createTable('users', (table) => { await knex.schema.createTable('users', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('email', 255).notNullable().unique(); table.string('email', 255).notNullable().unique();
table.string('password_hash', 255).notNullable(); table.string('password_hash', 255).notNullable();
@@ -24,7 +24,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Wallets table for storing encrypted private keys // Wallets table for storing encrypted private keys
await knex.schema.createTable('wallets', (table) => { await knex.schema.createTable('wallets', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('address', 42).notNullable().unique(); table.string('address', 42).notNullable().unique();
table.text('encrypted_private_key').notNullable(); table.text('encrypted_private_key').notNullable();
@@ -39,7 +39,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Blockchain events table // Blockchain events table
await knex.schema.createTable('blockchain_events', (table) => { await knex.schema.createTable('blockchain_events', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.string('tx_hash', 66).notNullable(); table.string('tx_hash', 66).notNullable();
table.string('event_name', 100).notNullable(); table.string('event_name', 100).notNullable();
@@ -62,7 +62,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Application logs table // Application logs table
await knex.schema.createTable('application_logs', (table) => { await knex.schema.createTable('application_logs', table => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.enum('level', ['DEBUG', 'INFO', 'WARN', 'ERROR']).notNullable(); table.enum('level', ['DEBUG', 'INFO', 'WARN', 'ERROR']).notNullable();
table.string('module', 100).notNullable(); table.string('module', 100).notNullable();
@@ -83,7 +83,7 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Add additional fields to departments table // Add additional fields to departments table
await knex.schema.alterTable('departments', (table) => { await knex.schema.alterTable('departments', table => {
table.text('description'); table.text('description');
table.string('contact_email', 255); table.string('contact_email', 255);
table.string('contact_phone', 20); table.string('contact_phone', 20);
@@ -93,7 +93,7 @@ export async function up(knex: Knex): Promise<void> {
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {
// Remove additional fields from departments // Remove additional fields from departments
await knex.schema.alterTable('departments', (table) => { await knex.schema.alterTable('departments', table => {
table.dropColumn('description'); table.dropColumn('description');
table.dropColumn('contact_email'); table.dropColumn('contact_email');
table.dropColumn('contact_phone'); table.dropColumn('contact_phone');

View File

@@ -2,19 +2,20 @@ import { Module, Global, Provider } from '@nestjs/common';
import * as models from './models'; import * as models from './models';
const modelProviders: Provider[] = Object.values(models) const modelProviders: Provider[] = Object.values(models)
.filter((model: any) => .filter(
typeof model === 'function' && (model: any) =>
model.prototype && typeof model === 'function' &&
(model.prototype instanceof models.BaseModel || model === models.BaseModel) model.prototype &&
) (model.prototype instanceof models.BaseModel || model === models.BaseModel),
.map((model: any) => ({ )
provide: model, .map((model: any) => ({
useValue: model, provide: model,
})); useValue: model,
}));
@Global() @Global()
@Module({ @Module({
providers: modelProviders, providers: modelProviders,
exports: modelProviders, exports: modelProviders,
}) })
export class ModelsModule { } export class ModelsModule {}

View File

@@ -21,7 +21,15 @@ export class DocumentVersion extends BaseModel {
static get jsonSchema() { static get jsonSchema() {
return { return {
type: 'object', type: 'object',
required: ['documentId', 'version', 'hash', 'minioPath', 'fileSize', 'mimeType', 'uploadedBy'], required: [
'documentId',
'version',
'hash',
'minioPath',
'fileSize',
'mimeType',
'uploadedBy',
],
properties: { properties: {
id: { type: 'string', format: 'uuid' }, id: { type: 'string', format: 'uuid' },
documentId: { type: 'string', format: 'uuid' }, documentId: { type: 'string', format: 'uuid' },

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,11 @@ async function bootstrap(): Promise<void> {
app.use(compression()); app.use(compression());
// CORS configuration - Allow multiple origins for local development // CORS configuration - Allow multiple origins for local development
const allowedOrigins = ['http://localhost:4200', 'http://localhost:3000', 'http://localhost:8080']; const allowedOrigins = [
'http://localhost:4200',
'http://localhost:3000',
'http://localhost:8080',
];
app.enableCors({ app.enableCors({
origin: (origin, callback) => { origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) { if (!origin || allowedOrigins.includes(origin)) {
@@ -38,7 +42,13 @@ async function bootstrap(): Promise<void> {
} }
}, },
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'x-department-code', 'x-correlation-id'], allowedHeaders: [
'Content-Type',
'Authorization',
'x-api-key',
'x-department-code',
'x-correlation-id',
],
credentials: true, credentials: true,
}); });
@@ -57,12 +67,13 @@ async function bootstrap(): Promise<void> {
transformOptions: { transformOptions: {
enableImplicitConversion: true, enableImplicitConversion: true,
}, },
exceptionFactory: (errors) => { exceptionFactory: errors => {
// Return first error message as string for better test compatibility // Return first error message as string for better test compatibility
const firstError = errors[0]; const firstError = errors[0];
const firstConstraint = firstError && firstError.constraints const firstConstraint =
? Object.values(firstError.constraints)[0] firstError && firstError.constraints
: 'Validation failed'; ? Object.values(firstError.constraints)[0]
: 'Validation failed';
return new (require('@nestjs/common').BadRequestException)(firstConstraint); return new (require('@nestjs/common').BadRequestException)(firstConstraint);
}, },
}), }),
@@ -72,10 +83,7 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
// Global interceptors // Global interceptors
app.useGlobalInterceptors( app.useGlobalInterceptors(new CorrelationIdInterceptor(), new LoggingInterceptor());
new CorrelationIdInterceptor(),
new LoggingInterceptor(),
);
// Swagger documentation // Swagger documentation
if (swaggerEnabled) { if (swaggerEnabled) {
@@ -142,7 +150,7 @@ async function bootstrap(): Promise<void> {
logger.log(`API endpoint: http://localhost:${port}/${apiPrefix}/${apiVersion}`); logger.log(`API endpoint: http://localhost:${port}/${apiPrefix}/${apiVersion}`);
} }
bootstrap().catch((error) => { bootstrap().catch(error => {
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
logger.error('Failed to start application', error); logger.error('Failed to start application', error);
process.exit(1); process.exit(1);

View File

@@ -36,7 +36,8 @@ export class AdminController {
@Get('stats') @Get('stats')
@ApiOperation({ @ApiOperation({
summary: 'Get platform statistics', summary: 'Get platform statistics',
description: 'Get overall platform statistics including request counts, user counts, and transaction data', description:
'Get overall platform statistics including request counts, user counts, and transaction data',
}) })
@ApiResponse({ status: 200, description: 'Platform statistics' }) @ApiResponse({ status: 200, description: 'Platform statistics' })
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
@@ -49,7 +50,8 @@ export class AdminController {
@Get('health') @Get('health')
@ApiOperation({ @ApiOperation({
summary: 'Get system health', summary: 'Get system health',
description: 'Get health status of all platform services (database, blockchain, storage, queue)', description:
'Get health status of all platform services (database, blockchain, storage, queue)',
}) })
@ApiResponse({ status: 200, description: 'System health status' }) @ApiResponse({ status: 200, description: 'System health status' })
async getHealth() { async getHealth() {
@@ -72,25 +74,51 @@ export class AdminController {
return this.adminService.getRecentActivity(limit || 20); return this.adminService.getRecentActivity(limit || 20);
} }
@Get('blockchain/blocks')
@ApiOperation({
summary: 'Get recent blockchain blocks',
description: 'Get the most recent blocks from the blockchain',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of blocks to fetch (default: 5)',
})
@ApiResponse({ status: 200, description: 'Recent blockchain blocks' })
async getBlockchainBlocks(@Query('limit') limit?: number) {
return this.adminService.getBlockchainBlocks(limit || 5);
}
@Get('blockchain/transactions') @Get('blockchain/transactions')
@ApiOperation({ @ApiOperation({
summary: 'List blockchain transactions', summary: 'List blockchain transactions',
description: 'Get paginated list of blockchain transactions with optional status filter', description: 'Get paginated list of blockchain transactions with optional status filter',
}) })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) @ApiQuery({
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' }) name: 'page',
@ApiQuery({ name: 'status', required: false, description: 'Filter by transaction status (PENDING, CONFIRMED, FAILED)' }) required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 20)',
})
@ApiQuery({
name: 'status',
required: false,
description: 'Filter by transaction status (PENDING, CONFIRMED, FAILED)',
})
@ApiResponse({ status: 200, description: 'Paginated blockchain transactions' }) @ApiResponse({ status: 200, description: 'Paginated blockchain transactions' })
async getBlockchainTransactions( async getBlockchainTransactions(
@Query('page') page?: number, @Query('page') page?: number,
@Query('limit') limit?: number, @Query('limit') limit?: number,
@Query('status') status?: string, @Query('status') status?: string,
) { ) {
return this.adminService.getBlockchainTransactions( return this.adminService.getBlockchainTransactions(page || 1, limit || 20, status);
page || 1,
limit || 20,
status,
);
} }
@Post('departments') @Post('departments')
@@ -123,13 +151,20 @@ export class AdminController {
summary: 'List all departments', summary: 'List all departments',
description: 'Get list of all departments with pagination', description: 'Get list of all departments with pagination',
}) })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) @ApiQuery({
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' }) name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 20)',
})
@ApiResponse({ status: 200, description: 'List of departments' }) @ApiResponse({ status: 200, description: 'List of departments' })
async getDepartments( async getDepartments(@Query('page') page?: number, @Query('limit') limit?: number) {
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.adminService.getDepartments(page || 1, limit || 20); return this.adminService.getDepartments(page || 1, limit || 20);
} }
@@ -203,8 +238,18 @@ export class AdminController {
summary: 'List blockchain events', summary: 'List blockchain events',
description: 'Get paginated list of blockchain events with optional filters', description: 'Get paginated list of blockchain events with optional filters',
}) })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) @ApiQuery({
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' }) name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 20)',
})
@ApiQuery({ name: 'eventType', required: false, description: 'Filter by event type' }) @ApiQuery({ name: 'eventType', required: false, description: 'Filter by event type' })
@ApiQuery({ name: 'contractAddress', required: false, description: 'Filter by contract address' }) @ApiQuery({ name: 'contractAddress', required: false, description: 'Filter by contract address' })
@ApiResponse({ status: 200, description: 'Paginated blockchain events' }) @ApiResponse({ status: 200, description: 'Paginated blockchain events' })
@@ -227,9 +272,23 @@ export class AdminController {
summary: 'List application logs', summary: 'List application logs',
description: 'Get paginated list of application logs with optional filters', description: 'Get paginated list of application logs with optional filters',
}) })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) @ApiQuery({
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 50)' }) name: 'page',
@ApiQuery({ name: 'level', required: false, description: 'Filter by log level (INFO, WARN, ERROR)' }) required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 50)',
})
@ApiQuery({
name: 'level',
required: false,
description: 'Filter by log level (INFO, WARN, ERROR)',
})
@ApiQuery({ name: 'module', required: false, description: 'Filter by module name' }) @ApiQuery({ name: 'module', required: false, description: 'Filter by module name' })
@ApiQuery({ name: 'search', required: false, description: 'Search in log messages' }) @ApiQuery({ name: 'search', required: false, description: 'Search in log messages' })
@ApiResponse({ status: 200, description: 'Paginated application logs' }) @ApiResponse({ status: 200, description: 'Paginated application logs' })
@@ -240,13 +299,7 @@ export class AdminController {
@Query('module') module?: string, @Query('module') module?: string,
@Query('search') search?: string, @Query('search') search?: string,
) { ) {
return this.adminService.getApplicationLogs( return this.adminService.getApplicationLogs(page || 1, limit || 50, level, module, search);
page || 1,
limit || 50,
level,
module,
search,
);
} }
@Get('documents/:requestId') @Get('documents/:requestId')

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, Inject } from '@nestjs/common'; import { Injectable, Logger, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ethers } from 'ethers';
import { LicenseRequest } from '../../database/models/license-request.model'; import { LicenseRequest } from '../../database/models/license-request.model';
import { Applicant } from '../../database/models/applicant.model'; import { Applicant } from '../../database/models/applicant.model';
import { Department } from '../../database/models/department.model'; import { Department } from '../../database/models/department.model';
@@ -12,16 +13,24 @@ import { AuditLog } from '../../database/models/audit-log.model';
import { DepartmentsService } from '../departments/departments.service'; import { DepartmentsService } from '../departments/departments.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
export interface StatusCount {
status: string;
count: number;
}
export interface PlatformStats { export interface PlatformStats {
totalRequests: number; totalRequests: number;
requestsByStatus: Record<string, number>; totalApprovals: number;
requestsByStatus: StatusCount[];
totalApplicants: number; totalApplicants: number;
activeApplicants: number; activeApplicants: number;
totalDepartments: number; totalDepartments: number;
activeDepartments: number; activeDepartments: number;
totalDocuments: number; totalDocuments: number;
totalBlockchainTransactions: number; totalBlockchainTransactions: number;
transactionsByStatus: Record<string, number>; transactionsByStatus: StatusCount[];
averageProcessingTime: number;
lastUpdated: string;
} }
export interface SystemHealth { export interface SystemHealth {
@@ -70,44 +79,43 @@ export class AdminService {
transactionsByStatus, transactionsByStatus,
] = await Promise.all([ ] = await Promise.all([
this.requestModel.query().resultSize(), this.requestModel.query().resultSize(),
this.requestModel this.requestModel.query().select('status').count('* as count').groupBy('status') as any,
.query()
.select('status')
.count('* as count')
.groupBy('status') as any,
this.applicantModel.query().resultSize(), this.applicantModel.query().resultSize(),
this.applicantModel.query().where({ isActive: true }).resultSize(), this.applicantModel.query().where({ is_active: true }).resultSize(),
this.departmentModel.query().resultSize(), this.departmentModel.query().resultSize(),
this.departmentModel.query().where({ isActive: true }).resultSize(), this.departmentModel.query().where({ is_active: true }).resultSize(),
this.documentModel.query().resultSize(), this.documentModel.query().resultSize(),
this.blockchainTxModel.query().resultSize(), this.blockchainTxModel.query().resultSize(),
this.blockchainTxModel this.blockchainTxModel.query().select('status').count('* as count').groupBy('status') as any,
.query()
.select('status')
.count('* as count')
.groupBy('status') as any,
]); ]);
const statusMap: Record<string, number> = {}; // Convert to array format expected by frontend
for (const row of requestsByStatus) { const statusArray: StatusCount[] = requestsByStatus.map((row: any) => ({
statusMap[(row as any).status] = parseInt((row as any).count, 10); status: row.status,
} count: parseInt(row.count, 10),
}));
const txStatusMap: Record<string, number> = {}; const txStatusArray: StatusCount[] = transactionsByStatus.map((row: any) => ({
for (const row of transactionsByStatus) { status: row.status,
txStatusMap[(row as any).status] = parseInt((row as any).count, 10); count: parseInt(row.count, 10),
} }));
// Calculate total approvals
const approvedCount = statusArray.find(s => s.status === 'APPROVED')?.count || 0;
return { return {
totalRequests, totalRequests,
requestsByStatus: statusMap, totalApprovals: approvedCount,
requestsByStatus: statusArray,
totalApplicants, totalApplicants,
activeApplicants, activeApplicants,
totalDepartments, totalDepartments,
activeDepartments, activeDepartments,
totalDocuments, totalDocuments,
totalBlockchainTransactions, totalBlockchainTransactions,
transactionsByStatus: txStatusMap, transactionsByStatus: txStatusArray,
averageProcessingTime: 4.5, // Placeholder
lastUpdated: new Date().toISOString(),
}; };
} }
@@ -121,8 +129,7 @@ export class AdminService {
dbStatus = 'down'; dbStatus = 'down';
} }
const overallStatus = const overallStatus = dbStatus === 'up' ? 'healthy' : 'unhealthy';
dbStatus === 'up' ? 'healthy' : 'unhealthy';
return { return {
status: overallStatus, status: overallStatus,
@@ -138,17 +145,10 @@ export class AdminService {
} }
async getRecentActivity(limit: number = 20): Promise<AuditLog[]> { async getRecentActivity(limit: number = 20): Promise<AuditLog[]> {
return this.auditLogModel return this.auditLogModel.query().orderBy('created_at', 'DESC').limit(limit);
.query()
.orderBy('created_at', 'DESC')
.limit(limit);
} }
async getBlockchainTransactions( async getBlockchainTransactions(page: number = 1, limit: number = 20, status?: string) {
page: number = 1,
limit: number = 20,
status?: string,
) {
const query = this.blockchainTxModel.query().orderBy('created_at', 'DESC'); const query = this.blockchainTxModel.query().orderBy('created_at', 'DESC');
if (status) { if (status) {
@@ -177,7 +177,8 @@ export class AdminService {
department: result.department, department: result.department,
apiKey: result.apiKey, apiKey: result.apiKey,
apiSecret: result.apiSecret, apiSecret: result.apiSecret,
message: 'Department onboarded successfully. Please save the API credentials as they will not be shown again.', message:
'Department onboarded successfully. Please save the API credentials as they will not be shown again.',
}; };
} }
@@ -197,7 +198,8 @@ export class AdminService {
const result = await this.departmentsService.regenerateApiKey(id); const result = await this.departmentsService.regenerateApiKey(id);
return { return {
...result, ...result,
message: 'API key regenerated successfully. Please save the new credentials as they will not be shown again.', message:
'API key regenerated successfully. Please save the new credentials as they will not be shown again.',
}; };
} }
@@ -221,9 +223,7 @@ export class AdminService {
eventType?: string, eventType?: string,
contractAddress?: string, contractAddress?: string,
) { ) {
const query = this.blockchainEventModel const query = this.blockchainEventModel.query().orderBy('created_at', 'DESC');
.query()
.orderBy('created_at', 'DESC');
if (eventType) { if (eventType) {
query.where({ eventType }); query.where({ eventType });
@@ -255,9 +255,7 @@ export class AdminService {
module?: string, module?: string,
search?: string, search?: string,
) { ) {
const query = this.appLogModel const query = this.appLogModel.query().orderBy('created_at', 'DESC');
.query()
.orderBy('created_at', 'DESC');
if (level) { if (level) {
query.where({ level }); query.where({ level });
@@ -293,7 +291,9 @@ export class AdminService {
const documents = await this.documentModel const documents = await this.documentModel
.query() .query()
.where({ requestId }) .where({ requestId })
.withGraphFetched('[uploadedByUser, versions.[uploadedByUser], departmentReviews.[department]]') .withGraphFetched(
'[uploadedByUser, versions.[uploadedByUser], departmentReviews.[department]]',
)
.orderBy('created_at', 'DESC'); .orderBy('created_at', 'DESC');
// Transform documents to include formatted data // Transform documents to include formatted data
@@ -309,22 +309,24 @@ export class AdminService {
uploadedAt: doc.createdAt, uploadedAt: doc.createdAt,
uploadedBy: doc.uploadedByUser?.name || 'Unknown', uploadedBy: doc.uploadedByUser?.name || 'Unknown',
currentVersion: doc.version || 1, currentVersion: doc.version || 1,
versions: doc.versions?.map((v: any) => ({ versions:
id: v.id, doc.versions?.map((v: any) => ({
version: v.version, id: v.id,
fileHash: v.fileHash, version: v.version,
uploadedAt: v.createdAt, fileHash: v.fileHash,
uploadedBy: v.uploadedByUser?.name || 'Unknown', uploadedAt: v.createdAt,
changes: v.changes, uploadedBy: v.uploadedByUser?.name || 'Unknown',
})) || [], changes: v.changes,
departmentReviews: doc.departmentReviews?.map((review: any) => ({ })) || [],
departmentCode: review.department?.code || 'UNKNOWN', departmentReviews:
departmentName: review.department?.name || 'Unknown Department', doc.departmentReviews?.map((review: any) => ({
reviewedAt: review.createdAt, departmentCode: review.department?.code || 'UNKNOWN',
reviewedBy: review.reviewedByUser?.name || 'Unknown', departmentName: review.department?.name || 'Unknown Department',
status: review.status, reviewedAt: review.createdAt,
comments: review.comments, reviewedBy: review.reviewedByUser?.name || 'Unknown',
})) || [], status: review.status,
comments: review.comments,
})) || [],
metadata: { metadata: {
mimeType: doc.mimeType, mimeType: doc.mimeType,
width: doc.width, width: doc.width,
@@ -333,4 +335,41 @@ export class AdminService {
}, },
})); }));
} }
async getBlockchainBlocks(limit: number = 5) {
this.logger.debug(`Fetching ${limit} recent blockchain blocks`);
try {
const rpcUrl = this.configService.get<string>('BESU_RPC_URL') || 'http://besu-node-1:8545';
const provider = new ethers.JsonRpcProvider(rpcUrl);
const latestBlockNumber = await provider.getBlockNumber();
const blocks = [];
for (let i = 0; i < limit && latestBlockNumber - i >= 0; i++) {
const blockNumber = latestBlockNumber - i;
const block = await provider.getBlock(blockNumber);
if (block) {
blocks.push({
blockNumber: block.number,
hash: block.hash,
parentHash: block.parentHash,
timestamp: new Date(block.timestamp * 1000).toISOString(),
transactionCount: block.transactions.length,
gasUsed: Number(block.gasUsed),
gasLimit: Number(block.gasLimit),
miner: block.miner,
nonce: block.nonce,
});
}
}
return { data: blocks };
} catch (error) {
this.logger.error('Failed to fetch blockchain blocks', error);
// Return empty array on error - frontend will use mock data
return { data: [] };
}
}
} }

View File

@@ -9,13 +9,7 @@ import {
UseGuards, UseGuards,
ParseUUIDPipe, ParseUUIDPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { ApplicantsService } from './applicants.service'; import { ApplicantsService } from './applicants.service';
import { CreateApplicantDto, UpdateApplicantDto, ApplicantResponseDto } from './dto'; import { CreateApplicantDto, UpdateApplicantDto, ApplicantResponseDto } from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -36,10 +30,7 @@ export class ApplicantsController {
@ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({ status: 200, description: 'List of applicants' }) @ApiResponse({ status: 200, description: 'List of applicants' })
async findAll( async findAll(@Query('page') page?: number, @Query('limit') limit?: number) {
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.applicantsService.findAll({ page, limit }); return this.applicantsService.findAll({ page, limit });
} }
@@ -53,6 +44,28 @@ export class ApplicantsController {
return this.applicantsService.findById(id); return this.applicantsService.findById(id);
} }
@Get(':id/stats')
@Roles(UserRole.ADMIN, UserRole.APPLICANT)
@ApiBearerAuth('BearerAuth')
@ApiOperation({ summary: 'Get applicant statistics' })
@ApiResponse({
status: 200,
description: 'Applicant statistics',
schema: {
properties: {
totalRequests: { type: 'number' },
pendingRequests: { type: 'number' },
approvedLicenses: { type: 'number' },
documentsUploaded: { type: 'number' },
blockchainRecords: { type: 'number' },
},
},
})
@ApiResponse({ status: 404, description: 'Applicant not found' })
async getStats(@Param('id', ParseUUIDPipe) id: string) {
return this.applicantsService.getStats(id);
}
@Post() @Post()
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)
@ApiBearerAuth('BearerAuth') @ApiBearerAuth('BearerAuth')
@@ -69,10 +82,7 @@ export class ApplicantsController {
@ApiOperation({ summary: 'Update applicant' }) @ApiOperation({ summary: 'Update applicant' })
@ApiResponse({ status: 200, description: 'Applicant updated', type: ApplicantResponseDto }) @ApiResponse({ status: 200, description: 'Applicant updated', type: ApplicantResponseDto })
@ApiResponse({ status: 404, description: 'Applicant not found' }) @ApiResponse({ status: 404, description: 'Applicant not found' })
async update( async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApplicantDto) {
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateApplicantDto,
) {
return this.applicantsService.update(id, dto); return this.applicantsService.update(id, dto);
} }
} }

View File

@@ -1,9 +1,17 @@
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException, Logger, Inject } from '@nestjs/common';
import { Applicant } from '../../database/models'; import { Applicant, LicenseRequest, Document } from '../../database/models';
import { CreateApplicantDto, UpdateApplicantDto } from './dto'; import { CreateApplicantDto, UpdateApplicantDto } from './dto';
import { ERROR_CODES } from '../../common/constants'; import { ERROR_CODES } from '../../common/constants';
import { paginate, PaginationOptions, PaginatedResult } from '../../common/utils/pagination.util'; import { paginate, PaginationOptions, PaginatedResult } from '../../common/utils/pagination.util';
export interface ApplicantStats {
totalRequests: number;
pendingRequests: number;
approvedLicenses: number;
documentsUploaded: number;
blockchainRecords: number;
}
@Injectable() @Injectable()
export class ApplicantsService { export class ApplicantsService {
private readonly logger = new Logger(ApplicantsService.name); private readonly logger = new Logger(ApplicantsService.name);
@@ -31,9 +39,7 @@ export class ApplicantsService {
} }
async findAll(options: PaginationOptions): Promise<PaginatedResult<Applicant>> { async findAll(options: PaginationOptions): Promise<PaginatedResult<Applicant>> {
const query = Applicant.query() const query = Applicant.query().where('is_active', true).orderBy('created_at', 'desc');
.where('is_active', true)
.orderBy('created_at', 'desc');
return await paginate(query, options.page, options.limit); return await paginate(query, options.page, options.limit);
} }
@@ -94,4 +100,48 @@ export class ApplicantsService {
await Applicant.query().patchAndFetchById(id, { isActive: false }); await Applicant.query().patchAndFetchById(id, { isActive: false });
this.logger.log(`Deactivated applicant: ${id}`); this.logger.log(`Deactivated applicant: ${id}`);
} }
async getStats(id: string): Promise<ApplicantStats> {
const applicant = await this.findById(id);
if (!applicant) {
throw new NotFoundException({
code: ERROR_CODES.APPLICANT_NOT_FOUND,
message: 'Applicant not found',
});
}
// Get all requests for this applicant
const requests = await LicenseRequest.query().where('applicant_id', id);
// Count pending requests (SUBMITTED, IN_REVIEW)
const pendingRequests = requests.filter(r =>
['SUBMITTED', 'IN_REVIEW'].includes(r.status),
).length;
// Count approved licenses
const approvedLicenses = requests.filter(r =>
['APPROVED', 'COMPLETED'].includes(r.status),
).length;
// Count blockchain records (requests with transaction hash)
const blockchainRecords = requests.filter(r => r.blockchainTxHash).length;
// Count documents for all requests
const requestIds = requests.map(r => r.id);
let documentsUploaded = 0;
if (requestIds.length > 0) {
const documents = await Document.query()
.whereIn('request_id', requestIds)
.where('is_active', true);
documentsUploaded = documents.length;
}
return {
totalRequests: requests.length,
pendingRequests,
approvedLicenses,
documentsUploaded,
blockchainRecords,
};
}
} }

View File

@@ -267,6 +267,58 @@ export class ApprovalsController {
return this.approvalsService.requestChanges(requestId, department.id, dto, user.sub); return this.approvalsService.requestChanges(requestId, department.id, dto, user.sub);
} }
@Get('pending')
@ApiOperation({
summary: 'Get pending approvals for current user',
description: 'Get paginated list of pending approvals for the current department officer',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 10)',
})
@ApiResponse({
status: 200,
description: 'Paginated list of pending approvals',
})
async getPendingApprovals(
@CurrentUser() user: JwtPayload,
@Query('page') page?: string,
@Query('limit') limit?: string,
@CorrelationId() correlationId?: string,
) {
this.logger.debug(`[${correlationId}] Fetching pending approvals for user: ${user.sub}`);
if (!user.departmentCode) {
// Return empty list for non-department users
return {
data: [],
meta: { page: 1, limit: 10, total: 0, totalPages: 0 },
};
}
// Look up department by code to get ID
const department = await this.departmentModel.query().findOne({ code: user.departmentCode });
if (!department) {
return {
data: [],
meta: { page: 1, limit: 10, total: 0, totalPages: 0 },
};
}
return this.approvalsService.findPendingByDepartment(department.id, {
page: parseInt(page || '1', 10),
limit: parseInt(limit || '10', 10),
});
}
@Get(':approvalId') @Get(':approvalId')
@ApiOperation({ @ApiOperation({
summary: 'Get approval by ID', summary: 'Get approval by ID',
@@ -323,10 +375,7 @@ export class ApprovalsController {
@CorrelationId() correlationId?: string, @CorrelationId() correlationId?: string,
): Promise<ApprovalResponseDto[]> { ): Promise<ApprovalResponseDto[]> {
this.logger.debug(`[${correlationId}] Fetching approvals for request: ${requestId}`); this.logger.debug(`[${correlationId}] Fetching approvals for request: ${requestId}`);
return this.approvalsService.findByRequestId( return this.approvalsService.findByRequestId(requestId, includeInvalidated === 'true');
requestId,
includeInvalidated === 'true',
);
} }
@Get('department/:departmentCode') @Get('department/:departmentCode')

View File

@@ -70,21 +70,24 @@ export class ApprovalsService {
} }
// Check if department already approved/rejected (takes priority over workflow step) // Check if department already approved/rejected (takes priority over workflow step)
const existingApproval = await this.approvalsRepository.query() const existingApproval = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('department_id', departmentId) .where('department_id', departmentId)
.whereNull('invalidated_at') .whereNull('invalidated_at')
.first(); .first();
if (existingApproval) { if (existingApproval) {
if (existingApproval.status === ApprovalStatus.APPROVED as any) { if (existingApproval.status === (ApprovalStatus.APPROVED as any)) {
throw new BadRequestException('Request already approved by your department'); throw new BadRequestException('Request already approved by your department');
} }
if (existingApproval.status === ApprovalStatus.REJECTED as any) { if (existingApproval.status === (ApprovalStatus.REJECTED as any)) {
throw new BadRequestException('Request already rejected by your department'); throw new BadRequestException('Request already rejected by your department');
} }
if (existingApproval.status !== ApprovalStatus.PENDING as any) { if (existingApproval.status !== (ApprovalStatus.PENDING as any)) {
throw new BadRequestException(`Cannot approve request with status ${existingApproval.status}`); throw new BadRequestException(
`Cannot approve request with status ${existingApproval.status}`,
);
} }
} }
@@ -93,7 +96,8 @@ export class ApprovalsService {
const deptCode = department?.code; const deptCode = department?.code;
// Check workflow step authorization // Check workflow step authorization
const workflowRequest = await this.requestsRepository.query() const workflowRequest = await this.requestsRepository
.query()
.findById(requestId) .findById(requestId)
.withGraphFetched('workflow'); .withGraphFetched('workflow');
@@ -116,19 +120,19 @@ export class ApprovalsService {
// Check if department is in current stage // Check if department is in current stage
if (currentStageIndex < definition.stages.length) { if (currentStageIndex < definition.stages.length) {
const currentStage = definition.stages[currentStageIndex]; const currentStage = definition.stages[currentStageIndex];
const isInCurrentStage = currentStage.requiredApprovals?.some((ra: any) => const isInCurrentStage = currentStage.requiredApprovals?.some(
ra.departmentCode === deptCode (ra: any) => ra.departmentCode === deptCode,
); );
if (!isInCurrentStage) { if (!isInCurrentStage) {
throw new ForbiddenException( throw new ForbiddenException(
'Your department is not assigned to the current workflow step' 'Your department is not assigned to the current workflow step',
); );
} }
} else { } else {
// All stages complete - department not in any active stage // All stages complete - department not in any active stage
throw new ForbiddenException( throw new ForbiddenException(
'Your department is not assigned to the current workflow step' 'Your department is not assigned to the current workflow step',
); );
} }
} }
@@ -137,9 +141,7 @@ export class ApprovalsService {
// Then check authorization // Then check authorization
const approval = await this.findPendingApproval(requestId, departmentId); const approval = await this.findPendingApproval(requestId, departmentId);
if (!approval) { if (!approval) {
throw new ForbiddenException( throw new ForbiddenException('Your department is not assigned to approve this request');
'Your department is not assigned to approve this request',
);
} }
// Use comments if remarks is not provided // Use comments if remarks is not provided
@@ -156,9 +158,8 @@ export class ApprovalsService {
} }
// Generate blockchain transaction hash for the approval // Generate blockchain transaction hash for the approval
const blockchainTxHash = '0x' + Array.from({ length: 64 }, () => const blockchainTxHash =
Math.floor(Math.random() * 16).toString(16) '0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
).join('');
const saved = await approval.$query().patchAndFetch({ const saved = await approval.$query().patchAndFetch({
status: (dto.status || ApprovalStatus.APPROVED) as any, status: (dto.status || ApprovalStatus.APPROVED) as any,
@@ -169,7 +170,8 @@ export class ApprovalsService {
}); });
// Fetch with department relation // Fetch with department relation
const result = await this.approvalsRepository.query() const result = await this.approvalsRepository
.query()
.findById(saved.id) .findById(saved.id)
.withGraphFetched('department'); .withGraphFetched('department');
@@ -200,10 +202,9 @@ export class ApprovalsService {
// If no next stage, mark request as approved // If no next stage, mark request as approved
if (!nextStageCreated) { if (!nextStageCreated) {
await this.requestsRepository.query() await this.requestsRepository.query().patchAndFetchById(requestId, {
.patchAndFetchById(requestId, { status: RequestStatus.APPROVED,
status: RequestStatus.APPROVED, });
});
} }
} }
@@ -217,7 +218,8 @@ export class ApprovalsService {
responseDto.workflowComplete = workflowComplete; responseDto.workflowComplete = workflowComplete;
// Calculate current step index // Calculate current step index
const workflowRequestForStep = await this.requestsRepository.query() const workflowRequestForStep = await this.requestsRepository
.query()
.findById(requestId) .findById(requestId)
.withGraphFetched('workflow'); .withGraphFetched('workflow');
@@ -285,17 +287,18 @@ export class ApprovalsService {
} }
// Check if department already approved/rejected (takes priority over workflow step) // Check if department already approved/rejected (takes priority over workflow step)
const existingApproval = await this.approvalsRepository.query() const existingApproval = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('department_id', departmentId) .where('department_id', departmentId)
.whereNull('invalidated_at') .whereNull('invalidated_at')
.first(); .first();
if (existingApproval) { if (existingApproval) {
if (existingApproval.status === ApprovalStatus.APPROVED as any) { if (existingApproval.status === (ApprovalStatus.APPROVED as any)) {
throw new BadRequestException('Request already approved by your department'); throw new BadRequestException('Request already approved by your department');
} }
if (existingApproval.status === ApprovalStatus.REJECTED as any) { if (existingApproval.status === (ApprovalStatus.REJECTED as any)) {
throw new BadRequestException('Request already rejected by your department'); throw new BadRequestException('Request already rejected by your department');
} }
} }
@@ -305,7 +308,8 @@ export class ApprovalsService {
const deptCode = department?.code; const deptCode = department?.code;
// Check workflow step authorization // Check workflow step authorization
const workflowRequest = await this.requestsRepository.query() const workflowRequest = await this.requestsRepository
.query()
.findById(requestId) .findById(requestId)
.withGraphFetched('workflow'); .withGraphFetched('workflow');
@@ -328,19 +332,19 @@ export class ApprovalsService {
// Check if department is in current stage // Check if department is in current stage
if (currentStageIndex < definition.stages.length) { if (currentStageIndex < definition.stages.length) {
const currentStage = definition.stages[currentStageIndex]; const currentStage = definition.stages[currentStageIndex];
const isInCurrentStage = currentStage.requiredApprovals?.some((ra: any) => const isInCurrentStage = currentStage.requiredApprovals?.some(
ra.departmentCode === deptCode (ra: any) => ra.departmentCode === deptCode,
); );
if (!isInCurrentStage) { if (!isInCurrentStage) {
throw new ForbiddenException( throw new ForbiddenException(
'Your department is not assigned to the current workflow step' 'Your department is not assigned to the current workflow step',
); );
} }
} else { } else {
// All stages complete - department not in any active stage // All stages complete - department not in any active stage
throw new ForbiddenException( throw new ForbiddenException(
'Your department is not assigned to the current workflow step' 'Your department is not assigned to the current workflow step',
); );
} }
} }
@@ -349,9 +353,7 @@ export class ApprovalsService {
// Then check authorization // Then check authorization
const approval = await this.findPendingApproval(requestId, departmentId); const approval = await this.findPendingApproval(requestId, departmentId);
if (!approval) { if (!approval) {
throw new ForbiddenException( throw new ForbiddenException('Your department is not assigned to this request');
'Your department is not assigned to this request',
);
} }
// Use comments if remarks is not provided // Use comments if remarks is not provided
@@ -364,13 +366,14 @@ export class ApprovalsService {
// Validate minimum length // Validate minimum length
if (remarks.trim().length < 5) { if (remarks.trim().length < 5) {
throw new BadRequestException('Detailed rejection comments must be at least 5 characters long'); throw new BadRequestException(
'Detailed rejection comments must be at least 5 characters long',
);
} }
// Generate blockchain transaction hash for the rejection // Generate blockchain transaction hash for the rejection
const blockchainTxHash = '0x' + Array.from({ length: 64 }, () => const blockchainTxHash =
Math.floor(Math.random() * 16).toString(16) '0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
).join('');
const saved = await approval.$query().patchAndFetch({ const saved = await approval.$query().patchAndFetch({
status: ApprovalStatus.REJECTED as any, status: ApprovalStatus.REJECTED as any,
@@ -380,7 +383,8 @@ export class ApprovalsService {
}); });
// Fetch with department relation for audit log // Fetch with department relation for audit log
const savedWithDept = await this.approvalsRepository.query() const savedWithDept = await this.approvalsRepository
.query()
.findById(saved.id) .findById(saved.id)
.withGraphFetched('department'); .withGraphFetched('department');
const departmentCode = (savedWithDept as any).department?.code || departmentId; const departmentCode = (savedWithDept as any).department?.code || departmentId;
@@ -392,7 +396,13 @@ export class ApprovalsService {
action: 'REQUEST_REJECTED', action: 'REQUEST_REJECTED',
actorType: 'DEPARTMENT', actorType: 'DEPARTMENT',
actorId: departmentId, actorId: departmentId,
newValue: { status: 'REJECTED', remarks, blockchainTxHash, performedBy: departmentCode, reason: dto.reason }, newValue: {
status: 'REJECTED',
remarks,
blockchainTxHash,
performedBy: departmentCode,
reason: dto.reason,
},
}); });
// Set additional fields in the response (not all persisted to DB) // Set additional fields in the response (not all persisted to DB)
@@ -404,10 +414,9 @@ export class ApprovalsService {
} }
// Update request status to REJECTED // Update request status to REJECTED
await this.requestsRepository.query() await this.requestsRepository.query().patchAndFetchById(requestId, {
.patchAndFetchById(requestId, { status: RequestStatus.REJECTED,
status: RequestStatus.REJECTED, });
});
return this.mapToResponseDto(saved); return this.mapToResponseDto(saved);
} }
@@ -424,9 +433,7 @@ export class ApprovalsService {
const approval = await this.findPendingApproval(requestId, departmentId); const approval = await this.findPendingApproval(requestId, departmentId);
if (!approval) { if (!approval) {
throw new ForbiddenException( throw new ForbiddenException('Your department is not assigned to this request');
'Your department is not assigned to this request',
);
} }
const saved = await approval.$query().patchAndFetch({ const saved = await approval.$query().patchAndFetch({
@@ -470,7 +477,38 @@ export class ApprovalsService {
} }
const approvals = await query.orderBy('created_at', 'ASC'); const approvals = await query.orderBy('created_at', 'ASC');
return approvals.map((a) => this.mapToResponseDto(a)); return approvals.map(a => this.mapToResponseDto(a));
}
/**
* Find pending approvals by department with pagination
*/
async findPendingByDepartment(
departmentId: string,
query: { page: number; limit: number },
): Promise<PaginatedResult<ApprovalResponseDto>> {
const page = query.page > 0 ? query.page - 1 : 0;
const limit = query.limit || 10;
const { results: approvals, total } = await this.approvalsRepository
.query()
.where('department_id', departmentId)
.where('status', 'PENDING')
.whereNull('invalidated_at')
.orderBy('created_at', 'DESC')
.page(page, limit);
return {
data: approvals.map(a => this.mapToResponseDto(a)),
meta: {
total,
page: query.page,
limit: limit,
totalPages: Math.ceil(total / limit),
hasNext: query.page < Math.ceil(total / limit),
hasPrev: query.page > 1,
},
};
} }
/** /**
@@ -483,14 +521,15 @@ export class ApprovalsService {
const page = query.page > 0 ? query.page - 1 : 0; const page = query.page > 0 ? query.page - 1 : 0;
const limit = query.limit || 10; const limit = query.limit || 10;
const { results: approvals, total } = await this.approvalsRepository.query() const { results: approvals, total } = await this.approvalsRepository
.query()
.where('department_id', departmentId) .where('department_id', departmentId)
.whereNull('invalidated_at') .whereNull('invalidated_at')
.orderBy('created_at', 'DESC') .orderBy('created_at', 'DESC')
.page(page, limit); .page(page, limit);
return { return {
data: approvals.map((a) => this.mapToResponseDto(a)), data: approvals.map(a => this.mapToResponseDto(a)),
meta: { meta: {
total, total,
page: query.page, page: query.page,
@@ -531,10 +570,7 @@ export class ApprovalsService {
const affectedDepartments: string[] = []; const affectedDepartments: string[] = [];
for (const approval of approvals) { for (const approval of approvals) {
if ( if (approval.reviewedDocuments && (approval.reviewedDocuments as any).includes(documentId)) {
approval.reviewedDocuments &&
(approval.reviewedDocuments as any).includes(documentId)
) {
if (approval.status === (ApprovalStatus.APPROVED as any)) { if (approval.status === (ApprovalStatus.APPROVED as any)) {
await approval.$query().patch({ await approval.$query().patch({
invalidatedAt: new Date(), invalidatedAt: new Date(),
@@ -551,10 +587,7 @@ export class ApprovalsService {
/** /**
* Revalidate an invalidated approval * Revalidate an invalidated approval
*/ */
async revalidateApproval( async revalidateApproval(approvalId: string, dto: RevalidateDto): Promise<ApprovalResponseDto> {
approvalId: string,
dto: RevalidateDto,
): Promise<ApprovalResponseDto> {
const approval = await this.approvalsRepository.query().findById(approvalId); const approval = await this.approvalsRepository.query().findById(approvalId);
if (!approval) { if (!approval) {
@@ -562,9 +595,7 @@ export class ApprovalsService {
} }
if (!approval.invalidatedAt) { if (!approval.invalidatedAt) {
throw new BadRequestException( throw new BadRequestException(`Approval ${approvalId} is not in an invalidated state`);
`Approval ${approvalId} is not in an invalidated state`,
);
} }
const saved = await approval.$query().patchAndFetch({ const saved = await approval.$query().patchAndFetch({
@@ -580,11 +611,9 @@ export class ApprovalsService {
/** /**
* Check if a department can approve at this stage * Check if a department can approve at this stage
*/ */
async canDepartmentApprove( async canDepartmentApprove(requestId: string, departmentId: string): Promise<boolean> {
requestId: string, const approval = await this.approvalsRepository
departmentId: string, .query()
): Promise<boolean> {
const approval = await this.approvalsRepository.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('department_id', departmentId) .where('department_id', departmentId)
.where('status', ApprovalStatus.PENDING as any) .where('status', ApprovalStatus.PENDING as any)
@@ -598,30 +627,33 @@ export class ApprovalsService {
* Get all pending approvals for a request * Get all pending approvals for a request
*/ */
async getPendingApprovals(requestId: string): Promise<ApprovalResponseDto[]> { async getPendingApprovals(requestId: string): Promise<ApprovalResponseDto[]> {
const approvals = await this.approvalsRepository.query() const approvals = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('status', ApprovalStatus.PENDING as any) .where('status', ApprovalStatus.PENDING as any)
.whereNull('invalidated_at'); .whereNull('invalidated_at');
return approvals.map((a) => this.mapToResponseDto(a)); return approvals.map(a => this.mapToResponseDto(a));
} }
/** /**
* Get all non-invalidated approvals for a request * Get all non-invalidated approvals for a request
*/ */
async getActiveApprovals(requestId: string): Promise<ApprovalResponseDto[]> { async getActiveApprovals(requestId: string): Promise<ApprovalResponseDto[]> {
const approvals = await this.approvalsRepository.query() const approvals = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.whereNull('invalidated_at'); .whereNull('invalidated_at');
return approvals.map((a) => this.mapToResponseDto(a)); return approvals.map(a => this.mapToResponseDto(a));
} }
/** /**
* Check if all approvals are complete for a request * Check if all approvals are complete for a request
*/ */
async areAllApprovalsComplete(requestId: string): Promise<boolean> { async areAllApprovalsComplete(requestId: string): Promise<boolean> {
const pendingCount = await this.approvalsRepository.query() const pendingCount = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('status', ApprovalStatus.PENDING as any) .where('status', ApprovalStatus.PENDING as any)
.whereNull('invalidated_at') .whereNull('invalidated_at')
@@ -633,14 +665,13 @@ export class ApprovalsService {
/** /**
* Get count of approvals by status * Get count of approvals by status
*/ */
async getApprovalCountByStatus( async getApprovalCountByStatus(requestId: string): Promise<Record<ApprovalStatus, number>> {
requestId: string,
): Promise<Record<ApprovalStatus, number>> {
const statuses = Object.values(ApprovalStatus); const statuses = Object.values(ApprovalStatus);
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const status of statuses) { for (const status of statuses) {
const count = await this.approvalsRepository.query() const count = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('status', status as any) .where('status', status as any)
.whereNull('invalidated_at') .whereNull('invalidated_at')
@@ -655,7 +686,7 @@ export class ApprovalsService {
* Hash remarks for integrity verification * Hash remarks for integrity verification
*/ */
hashRemarks(remarks: string): string { hashRemarks(remarks: string): string {
if(!remarks) return null; if (!remarks) return null;
return crypto.createHash('sha256').update(remarks).digest('hex'); return crypto.createHash('sha256').update(remarks).digest('hex');
} }
@@ -673,7 +704,8 @@ export class ApprovalsService {
requestId: string, requestId: string,
departmentId: string, departmentId: string,
): Promise<Approval | null> { ): Promise<Approval | null> {
return this.approvalsRepository.query() return this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('department_id', departmentId) .where('department_id', departmentId)
.where('status', ApprovalStatus.PENDING as any) .where('status', ApprovalStatus.PENDING as any)
@@ -686,7 +718,8 @@ export class ApprovalsService {
* Returns true if next stage was created, false if no more stages * Returns true if next stage was created, false if no more stages
*/ */
private async createNextStageApprovals(requestId: string): Promise<boolean> { private async createNextStageApprovals(requestId: string): Promise<boolean> {
const request = await this.requestsRepository.query() const request = await this.requestsRepository
.query()
.findById(requestId) .findById(requestId)
.withGraphFetched('workflow'); .withGraphFetched('workflow');
@@ -702,7 +735,8 @@ export class ApprovalsService {
} }
// Get all approvals for this request with department info // Get all approvals for this request with department info
const allApprovals = await this.approvalsRepository.query() const allApprovals = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.whereNull('invalidated_at') .whereNull('invalidated_at')
.withGraphFetched('department'); .withGraphFetched('department');
@@ -740,12 +774,14 @@ export class ApprovalsService {
const nextStage = stages[currentStageIndex]; const nextStage = stages[currentStageIndex];
for (const deptApproval of nextStage.requiredApprovals || []) { for (const deptApproval of nextStage.requiredApprovals || []) {
const department = await this.departmentRepository.query() const department = await this.departmentRepository
.query()
.findOne({ code: deptApproval.departmentCode }); .findOne({ code: deptApproval.departmentCode });
if (department) { if (department) {
// Check if approval already exists for this department // Check if approval already exists for this department
const existing = await this.approvalsRepository.query() const existing = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.where('department_id', department.id) .where('department_id', department.id)
.whereNull('invalidated_at') .whereNull('invalidated_at')
@@ -777,7 +813,8 @@ export class ApprovalsService {
const stageDeptCodes = stage.requiredApprovals.map((ra: any) => ra.departmentCode); const stageDeptCodes = stage.requiredApprovals.map((ra: any) => ra.departmentCode);
const approvals = await this.approvalsRepository.query() const approvals = await this.approvalsRepository
.query()
.where('request_id', requestId) .where('request_id', requestId)
.whereNull('invalidated_at') .whereNull('invalidated_at')
.withGraphFetched('department'); .withGraphFetched('department');
@@ -809,8 +846,10 @@ export class ApprovalsService {
status: approval.status as any, status: approval.status as any,
approvedBy: (approval as any).approvedBy, approvedBy: (approval as any).approvedBy,
rejectedBy: (approval as any).approvedBy, // Alias for approvedBy rejectedBy: (approval as any).approvedBy, // Alias for approvedBy
approvedAt: approval.status === (ApprovalStatus.APPROVED as any) ? approval.updatedAt : undefined, approvedAt:
rejectedAt: approval.status === (ApprovalStatus.REJECTED as any) ? approval.updatedAt : undefined, approval.status === (ApprovalStatus.APPROVED as any) ? approval.updatedAt : undefined,
rejectedAt:
approval.status === (ApprovalStatus.REJECTED as any) ? approval.updatedAt : undefined,
remarks: approval.remarks, remarks: approval.remarks,
comments: approval.remarks, // Alias for remarks comments: approval.remarks, // Alias for remarks
reviewedDocuments: approval.reviewedDocuments as any, reviewedDocuments: approval.reviewedDocuments as any,

View File

@@ -7,7 +7,8 @@ export class RejectRequestDto {
description: 'Detailed remarks explaining the rejection', description: 'Detailed remarks explaining the rejection',
minLength: 5, minLength: 5,
maxLength: 1000, maxLength: 1000,
example: 'The fire safety certificate provided is expired. Please provide an updated certificate.', example:
'The fire safety certificate provided is expired. Please provide an updated certificate.',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -1,11 +1,4 @@
import { import { Controller, Get, Query, Param, UseGuards, Logger } from '@nestjs/common';
Controller,
Get,
Query,
Param,
UseGuards,
Logger,
} from '@nestjs/common';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -30,20 +23,43 @@ export class AuditController {
constructor(private readonly auditService: AuditService) {} constructor(private readonly auditService: AuditService) {}
@Get('logs') @Get()
@ApiOperation({ @ApiOperation({
summary: 'Query audit logs', summary: 'Query audit logs',
description: 'Get paginated audit logs with optional filters by entity, action, actor, and date range', description:
'Get paginated audit logs with optional filters by entity, action, actor, and date range',
})
@ApiQuery({
name: 'entityType',
required: false,
description: 'Filter by entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)',
}) })
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)' })
@ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' }) @ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' })
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, APPROVE, REJECT, etc.)' }) @ApiQuery({
@ApiQuery({ name: 'actorType', required: false, description: 'Filter by actor type (APPLICANT, DEPARTMENT, SYSTEM, ADMIN)' }) name: 'action',
required: false,
description: 'Filter by action (CREATE, UPDATE, DELETE, APPROVE, REJECT, etc.)',
})
@ApiQuery({
name: 'actorType',
required: false,
description: 'Filter by actor type (APPLICANT, DEPARTMENT, SYSTEM, ADMIN)',
})
@ApiQuery({ name: 'actorId', required: false, description: 'Filter by actor ID' }) @ApiQuery({ name: 'actorId', required: false, description: 'Filter by actor ID' })
@ApiQuery({ name: 'startDate', required: false, description: 'Filter from date (ISO 8601)' }) @ApiQuery({ name: 'startDate', required: false, description: 'Filter from date (ISO 8601)' })
@ApiQuery({ name: 'endDate', required: false, description: 'Filter to date (ISO 8601)' }) @ApiQuery({ name: 'endDate', required: false, description: 'Filter to date (ISO 8601)' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) @ApiQuery({
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' }) name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 20)',
})
@ApiResponse({ status: 200, description: 'Paginated audit logs' }) @ApiResponse({ status: 200, description: 'Paginated audit logs' })
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' }) @ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
@@ -68,13 +84,13 @@ export class AuditController {
summary: 'Get audit trail for entity', summary: 'Get audit trail for entity',
description: 'Get complete audit trail for a specific entity (e.g., all changes to a request)', description: 'Get complete audit trail for a specific entity (e.g., all changes to a request)',
}) })
@ApiParam({ name: 'entityType', description: 'Entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)' }) @ApiParam({
name: 'entityType',
description: 'Entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)',
})
@ApiParam({ name: 'entityId', description: 'Entity ID (UUID)' }) @ApiParam({ name: 'entityId', description: 'Entity ID (UUID)' })
@ApiResponse({ status: 200, description: 'Audit trail for entity' }) @ApiResponse({ status: 200, description: 'Audit trail for entity' })
async findByEntity( async findByEntity(@Param('entityType') entityType: string, @Param('entityId') entityId: string) {
@Param('entityType') entityType: string,
@Param('entityId') entityId: string,
) {
return this.auditService.findByEntity(entityType, entityId); return this.auditService.findByEntity(entityType, entityId);
} }

View File

@@ -37,9 +37,7 @@ export class AuditService {
) {} ) {}
async record(dto: CreateAuditLogDto): Promise<AuditLog> { async record(dto: CreateAuditLogDto): Promise<AuditLog> {
this.logger.debug( this.logger.debug(`Recording audit: ${dto.action} on ${dto.entityType}/${dto.entityId}`);
`Recording audit: ${dto.action} on ${dto.entityType}/${dto.entityId}`,
);
return this.auditLogModel.query().insert({ return this.auditLogModel.query().insert({
entityType: dto.entityType, entityType: dto.entityType,
@@ -106,15 +104,21 @@ export class AuditService {
.orderBy('created_at', 'DESC'); .orderBy('created_at', 'DESC');
// Transform to add performedBy and details fields from newValue // Transform to add performedBy and details fields from newValue
return logs.map((log) => ({ return logs.map(log => ({
...log, ...log,
performedBy: (log.newValue as any)?.performedBy, performedBy: (log.newValue as any)?.performedBy,
details: (log.newValue as any)?.reason || (log.newValue as any)?.remarks || details:
(log.action === 'REQUEST_CANCELLED' ? (log.newValue as any)?.reason : undefined), (log.newValue as any)?.reason ||
(log.newValue as any)?.remarks ||
(log.action === 'REQUEST_CANCELLED' ? (log.newValue as any)?.reason : undefined),
})); }));
} }
async getEntityActions(): Promise<{ actions: string[]; entityTypes: string[]; actorTypes: string[] }> { async getEntityActions(): Promise<{
actions: string[];
entityTypes: string[];
actorTypes: string[];
}> {
return { return {
actions: Object.values(AuditAction), actions: Object.values(AuditAction),
entityTypes: Object.values(EntityType), entityTypes: Object.values(EntityType),

View File

@@ -7,7 +7,7 @@ import {
EmailPasswordLoginDto, EmailPasswordLoginDto,
LoginResponseDto, LoginResponseDto,
DigiLockerLoginResponseDto, DigiLockerLoginResponseDto,
UserLoginResponseDto UserLoginResponseDto,
} from './dto'; } from './dto';
@ApiTags('Auth') @ApiTags('Auth')

View File

@@ -139,9 +139,7 @@ export class AuthService {
/** /**
* Email/Password login for all user types (Admin, Department, Citizen) * Email/Password login for all user types (Admin, Department, Citizen)
*/ */
async emailPasswordLogin( async emailPasswordLogin(dto: EmailPasswordLoginDto): Promise<{
dto: EmailPasswordLoginDto,
): Promise<{
accessToken: string; accessToken: string;
user: { user: {
id: string; id: string;

View File

@@ -18,6 +18,6 @@ export class RolesGuard implements CanActivate {
} }
const { user } = context.switchToHttp().getRequest(); const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role); return requiredRoles.some(role => user?.role === role);
} }
} }

View File

@@ -32,7 +32,10 @@ export class ApprovalChainService {
try { try {
this.logger.debug(`Recording approval for request: ${requestId}`); this.logger.debug(`Recording approval for request: ${requestId}`);
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any; const contract = this.web3Service.getContract(
contractAddress,
this.approvalManagerAbi,
) as any;
// Map status string to enum number // Map status string to enum number
const statusMap = { const statusMap = {
@@ -66,7 +69,10 @@ export class ApprovalChainService {
requestId: string, requestId: string,
): Promise<OnChainApproval[]> { ): Promise<OnChainApproval[]> {
try { try {
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any; const contract = this.web3Service.getContract(
contractAddress,
this.approvalManagerAbi,
) as any;
const approvals = await contract.getRequestApprovals(requestId); const approvals = await contract.getRequestApprovals(requestId);
@@ -89,14 +95,14 @@ export class ApprovalChainService {
} }
} }
async invalidateApproval( async invalidateApproval(contractAddress: string, approvalId: string): Promise<string> {
contractAddress: string,
approvalId: string,
): Promise<string> {
try { try {
this.logger.debug(`Invalidating approval: ${approvalId}`); this.logger.debug(`Invalidating approval: ${approvalId}`);
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any; const contract = this.web3Service.getContract(
contractAddress,
this.approvalManagerAbi,
) as any;
const tx = await contract.invalidateApproval(approvalId); const tx = await contract.invalidateApproval(approvalId);
const receipt = await this.web3Service.sendTransaction(tx); const receipt = await this.web3Service.sendTransaction(tx);
@@ -115,7 +121,10 @@ export class ApprovalChainService {
remarksHash: string, remarksHash: string,
): Promise<boolean> { ): Promise<boolean> {
try { try {
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any; const contract = this.web3Service.getContract(
contractAddress,
this.approvalManagerAbi,
) as any;
return await contract.verifyApproval(approvalId, remarksHash); return await contract.verifyApproval(approvalId, remarksHash);
} catch (error) { } catch (error) {
@@ -124,12 +133,12 @@ export class ApprovalChainService {
} }
} }
async getApprovalDetails( async getApprovalDetails(contractAddress: string, approvalId: string): Promise<OnChainApproval> {
contractAddress: string,
approvalId: string,
): Promise<OnChainApproval> {
try { try {
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any; const contract = this.web3Service.getContract(
contractAddress,
this.approvalManagerAbi,
) as any;
const approval = await contract.getApprovalDetails(approvalId); const approval = await contract.getApprovalDetails(approvalId);

View File

@@ -77,9 +77,7 @@ export class BlockchainMonitorService {
await this.sleep(this.POLL_INTERVAL); await this.sleep(this.POLL_INTERVAL);
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
this.logger.warn( this.logger.warn(`Error polling transaction ${txHash}: ${lastError.message}`);
`Error polling transaction ${txHash}: ${lastError.message}`,
);
attempts++; attempts++;
await this.sleep(this.POLL_INTERVAL); await this.sleep(this.POLL_INTERVAL);
} }
@@ -178,7 +176,8 @@ export class BlockchainMonitorService {
errorMessage?: string, errorMessage?: string,
): Promise<void> { ): Promise<void> {
try { try {
await this.blockchainTxRepository.query() await this.blockchainTxRepository
.query()
.where({ txHash }) .where({ txHash })
.patch({ .patch({
status, status,
@@ -188,14 +187,11 @@ export class BlockchainMonitorService {
errorMessage: errorMessage || undefined, errorMessage: errorMessage || undefined,
}); });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(`Failed to update transaction status in database: ${txHash}`, error);
`Failed to update transaction status in database: ${txHash}`,
error,
);
} }
} }
private sleep(ms: number): Promise<void> { private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
} }

View File

@@ -35,10 +35,7 @@ export class DocumentChainService {
this.logger.log(`Document hash recorded: ${receipt.hash}`); this.logger.log(`Document hash recorded: ${receipt.hash}`);
return receipt.hash!; return receipt.hash!;
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(`Failed to record document hash for ${documentId}`, error);
`Failed to record document hash for ${documentId}`,
error,
);
throw error; throw error;
} }
} }
@@ -52,9 +49,7 @@ export class DocumentChainService {
const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any; const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any;
const isValid = await contract.verifyDocumentHash(documentId, hash); const isValid = await contract.verifyDocumentHash(documentId, hash);
this.logger.debug( this.logger.debug(`Document hash verification: documentId=${documentId}, isValid=${isValid}`);
`Document hash verification: documentId=${documentId}, isValid=${isValid}`,
);
return isValid; return isValid;
} catch (error) { } catch (error) {
this.logger.error(`Failed to verify document hash for ${documentId}`, error); this.logger.error(`Failed to verify document hash for ${documentId}`, error);
@@ -85,10 +80,7 @@ export class DocumentChainService {
} }
} }
async getLatestDocumentHash( async getLatestDocumentHash(contractAddress: string, documentId: string): Promise<string> {
contractAddress: string,
documentId: string,
): Promise<string> {
try { try {
const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any; const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any;

View File

@@ -74,10 +74,7 @@ export class LicenseNFTService {
} }
} }
async getLicenseMetadata( async getLicenseMetadata(contractAddress: string, tokenId: bigint): Promise<LicenseMetadata> {
contractAddress: string,
tokenId: bigint,
): Promise<LicenseMetadata> {
try { try {
const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any; const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any;
@@ -135,10 +132,7 @@ export class LicenseNFTService {
} }
} }
async getTokenIdByRequest( async getTokenIdByRequest(contractAddress: string, requestId: string): Promise<bigint | null> {
contractAddress: string,
requestId: string,
): Promise<bigint | null> {
try { try {
const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any; const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any;
return await contract.tokenOfRequest(requestId); return await contract.tokenOfRequest(requestId);
@@ -148,10 +142,7 @@ export class LicenseNFTService {
} }
} }
async verifyLicense( async verifyLicense(contractAddress: string, tokenId: bigint): Promise<LicenseVerification> {
contractAddress: string,
tokenId: bigint,
): Promise<LicenseVerification> {
try { try {
const isValid = await this.isLicenseValid(contractAddress, tokenId); const isValid = await this.isLicenseValid(contractAddress, tokenId);
const metadata = await this.getLicenseMetadata(contractAddress, tokenId); const metadata = await this.getLicenseMetadata(contractAddress, tokenId);

View File

@@ -53,10 +53,7 @@ export class Web3Service implements OnModuleInit {
return this.wallet; return this.wallet;
} }
getContract<T extends ethers.BaseContract>( getContract<T extends ethers.BaseContract>(address: string, abi: ethers.InterfaceAbi): T {
address: string,
abi: ethers.InterfaceAbi,
): T {
if (!this.wallet) { if (!this.wallet) {
throw new Error('Wallet not initialized'); throw new Error('Wallet not initialized');
} }
@@ -116,14 +113,12 @@ export class Web3Service implements OnModuleInit {
return receipt; return receipt;
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
this.logger.warn( this.logger.warn(`Transaction attempt ${attempt + 1} failed: ${lastError.message}`);
`Transaction attempt ${attempt + 1} failed: ${lastError.message}`,
);
// Check if it's a nonce issue // Check if it's a nonce issue
if (lastError.message.includes('nonce') || lastError.message.includes('replacement')) { if (lastError.message.includes('nonce') || lastError.message.includes('replacement')) {
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
continue; continue;
} }
} }

View File

@@ -65,7 +65,10 @@ export class WalletService {
/** /**
* Get wallet by owner * Get wallet by owner
*/ */
async getWalletByOwner(ownerType: 'USER' | 'DEPARTMENT', ownerId: string): Promise<Wallet | undefined> { async getWalletByOwner(
ownerType: 'USER' | 'DEPARTMENT',
ownerId: string,
): Promise<Wallet | undefined> {
return this.walletModel.query().findOne({ return this.walletModel.query().findOne({
ownerType, ownerType,
ownerId, ownerId,

View File

@@ -139,12 +139,10 @@ export class DepartmentsController {
`[${correlationId}] Fetching departments - page: ${query.page}, limit: ${query.limit}`, `[${correlationId}] Fetching departments - page: ${query.page}, limit: ${query.limit}`,
); );
const { results, total } = await this.departmentsService.findAll(query); const { data, total } = await this.departmentsService.findAll(query);
return { return {
data: results.map((department) => data: data.map(department => DepartmentResponseDto.fromEntity(department)),
DepartmentResponseDto.fromEntity(department),
),
meta: { meta: {
total, total,
page: query.page, page: query.page,
@@ -154,17 +152,17 @@ export class DepartmentsController {
}; };
} }
@Get(':code') @Get(':identifier')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: 'Get department by code', summary: 'Get department by ID or code',
description: 'Retrieve detailed information about a specific department', description: 'Retrieve detailed information about a specific department by UUID or code',
}) })
@ApiParam({ @ApiParam({
name: 'code', name: 'identifier',
description: 'Department code', description: 'Department ID (UUID) or code',
example: 'DEPT_001', example: 'REVENUE_DEPT',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -179,27 +177,33 @@ export class DepartmentsController {
status: 404, status: 404,
description: 'Department not found', description: 'Department not found',
}) })
async findByCode( async findByIdentifier(
@Param('code') code: string, @Param('identifier') identifier: string,
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
): Promise<DepartmentResponseDto> { ): Promise<DepartmentResponseDto> {
this.logger.debug(`[${correlationId}] Fetching department: ${code}`); this.logger.debug(`[${correlationId}] Fetching department: ${identifier}`);
try { try {
const department = await this.departmentsService.findByCode(code); // Check if identifier is a UUID format
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const isUuid = uuidRegex.test(identifier);
const department = isUuid
? await this.departmentsService.findById(identifier)
: await this.departmentsService.findByCode(identifier);
return DepartmentResponseDto.fromEntity(department); return DepartmentResponseDto.fromEntity(department);
} catch (error) { } catch (error) {
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
throw error; throw error;
} }
this.logger.error( this.logger.error(`[${correlationId}] Error fetching department: ${error.message}`);
`[${correlationId}] Error fetching department: ${error.message}`,
);
throw error; throw error;
} }
} }
@Patch(':code') @Patch(':identifier')
@UseGuards(AuthGuard('jwt'), RolesGuard) @UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('ADMIN') @Roles('ADMIN')
@ApiBearerAuth() @ApiBearerAuth()
@@ -208,9 +212,9 @@ export class DepartmentsController {
description: 'Update department information (admin only)', description: 'Update department information (admin only)',
}) })
@ApiParam({ @ApiParam({
name: 'code', name: 'identifier',
description: 'Department code', description: 'Department ID (UUID) or code',
example: 'DEPT_001', example: 'REVENUE_DEPT',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -230,25 +234,26 @@ export class DepartmentsController {
description: 'Department not found', description: 'Department not found',
}) })
async update( async update(
@Param('code') code: string, @Param('identifier') identifier: string,
@Body() updateDepartmentDto: UpdateDepartmentDto, @Body() updateDepartmentDto: UpdateDepartmentDto,
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
): Promise<DepartmentResponseDto> { ): Promise<DepartmentResponseDto> {
this.logger.debug( this.logger.debug(`[${correlationId}] Updating department: ${identifier}`);
`[${correlationId}] Updating department: ${code}`,
);
const department = await this.departmentsService.findByCode(code); const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const updated = await this.departmentsService.update( const isUuid = uuidRegex.test(identifier);
department.id,
updateDepartmentDto,
);
this.logger.log(`[${correlationId}] Department updated: ${code}`); const department = isUuid
? await this.departmentsService.findById(identifier)
: await this.departmentsService.findByCode(identifier);
const updated = await this.departmentsService.update(department.id, updateDepartmentDto);
this.logger.log(`[${correlationId}] Department updated: ${identifier}`);
return DepartmentResponseDto.fromEntity(updated); return DepartmentResponseDto.fromEntity(updated);
} }
@Post(':code/regenerate-api-key') @Post(':identifier/regenerate-api-key')
@UseGuards(AuthGuard('jwt'), RolesGuard) @UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('ADMIN') @Roles('ADMIN')
@ApiBearerAuth() @ApiBearerAuth()
@@ -259,9 +264,9 @@ export class DepartmentsController {
'Generate a new API key pair for the department (admin only). Old key will be invalidated.', 'Generate a new API key pair for the department (admin only). Old key will be invalidated.',
}) })
@ApiParam({ @ApiParam({
name: 'code', name: 'identifier',
description: 'Department code', description: 'Department ID (UUID) or code',
example: 'DEPT_001', example: 'REVENUE_DEPT',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -282,33 +287,35 @@ export class DepartmentsController {
description: 'Department not found', description: 'Department not found',
}) })
async regenerateApiKey( async regenerateApiKey(
@Param('code') code: string, @Param('identifier') identifier: string,
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
): Promise<{ apiKey: string; apiSecret: string }> { ): Promise<{ apiKey: string; apiSecret: string }> {
this.logger.debug( this.logger.debug(`[${correlationId}] Regenerating API key for department: ${identifier}`);
`[${correlationId}] Regenerating API key for department: ${code}`,
);
const department = await this.departmentsService.findByCode(code); const department = await this.findDepartmentByIdentifier(identifier);
const result = await this.departmentsService.regenerateApiKey( const result = await this.departmentsService.regenerateApiKey(department.id);
department.id,
);
this.logger.log( this.logger.log(`[${correlationId}] API key regenerated for department: ${identifier}`);
`[${correlationId}] API key regenerated for department: ${code}`,
);
return result; return result;
} }
@Get(':code/stats') private async findDepartmentByIdentifier(identifier: string) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const isUuid = uuidRegex.test(identifier);
return isUuid
? await this.departmentsService.findById(identifier)
: await this.departmentsService.findByCode(identifier);
}
@Get(':identifier/stats')
@ApiOperation({ @ApiOperation({
summary: 'Get department statistics', summary: 'Get department statistics',
description: 'Retrieve statistics for a specific department', description: 'Retrieve statistics for a specific department',
}) })
@ApiParam({ @ApiParam({
name: 'code', name: 'identifier',
description: 'Department code', description: 'Department ID (UUID) or code',
example: 'DEPT_001', example: 'REVENUE_DEPT',
}) })
@ApiQuery({ @ApiQuery({
name: 'startDate', name: 'startDate',
@@ -332,14 +339,12 @@ export class DepartmentsController {
description: 'Department not found', description: 'Department not found',
}) })
async getStats( async getStats(
@Param('code') code: string, @Param('identifier') identifier: string,
@Query('startDate') startDateStr?: string, @Query('startDate') startDateStr?: string,
@Query('endDate') endDateStr?: string, @Query('endDate') endDateStr?: string,
@CorrelationId() correlationId?: string, @CorrelationId() correlationId?: string,
): Promise<DepartmentStatsDto> { ): Promise<DepartmentStatsDto> {
this.logger.debug( this.logger.debug(`[${correlationId}] Fetching statistics for department: ${identifier}`);
`[${correlationId}] Fetching statistics for department: ${code}`,
);
let startDate: Date | undefined; let startDate: Date | undefined;
let endDate: Date | undefined; let endDate: Date | undefined;
@@ -361,11 +366,12 @@ export class DepartmentsController {
throw new BadRequestException('Invalid date format'); throw new BadRequestException('Invalid date format');
} }
const stats = await this.departmentsService.getStats(code, startDate, endDate); const department = await this.findDepartmentByIdentifier(identifier);
const stats = await this.departmentsService.getStats(department.code, startDate, endDate);
return stats; return stats;
} }
@Post(':code/activate') @Post(':identifier/activate')
@UseGuards(AuthGuard('jwt'), RolesGuard) @UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('ADMIN') @Roles('ADMIN')
@ApiBearerAuth() @ApiBearerAuth()
@@ -375,9 +381,9 @@ export class DepartmentsController {
description: 'Activate a deactivated department (admin only)', description: 'Activate a deactivated department (admin only)',
}) })
@ApiParam({ @ApiParam({
name: 'code', name: 'identifier',
description: 'Department code', description: 'Department ID (UUID) or code',
example: 'DEPT_001', example: 'REVENUE_DEPT',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -393,19 +399,19 @@ export class DepartmentsController {
description: 'Department not found', description: 'Department not found',
}) })
async activate( async activate(
@Param('code') code: string, @Param('identifier') identifier: string,
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
): Promise<DepartmentResponseDto> { ): Promise<DepartmentResponseDto> {
this.logger.debug(`[${correlationId}] Activating department: ${code}`); this.logger.debug(`[${correlationId}] Activating department: ${identifier}`);
const department = await this.departmentsService.findByCode(code); const department = await this.findDepartmentByIdentifier(identifier);
const activated = await this.departmentsService.activate(department.id); const activated = await this.departmentsService.activate(department.id);
this.logger.log(`[${correlationId}] Department activated: ${code}`); this.logger.log(`[${correlationId}] Department activated: ${identifier}`);
return DepartmentResponseDto.fromEntity(activated); return DepartmentResponseDto.fromEntity(activated);
} }
@Post(':code/deactivate') @Post(':identifier/deactivate')
@UseGuards(AuthGuard('jwt'), RolesGuard) @UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('ADMIN') @Roles('ADMIN')
@ApiBearerAuth() @ApiBearerAuth()
@@ -415,9 +421,9 @@ export class DepartmentsController {
description: 'Deactivate a department (admin only)', description: 'Deactivate a department (admin only)',
}) })
@ApiParam({ @ApiParam({
name: 'code', name: 'identifier',
description: 'Department code', description: 'Department ID (UUID) or code',
example: 'DEPT_001', example: 'REVENUE_DEPT',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -432,15 +438,15 @@ export class DepartmentsController {
description: 'Department not found', description: 'Department not found',
}) })
async deactivate( async deactivate(
@Param('code') code: string, @Param('identifier') identifier: string,
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
this.logger.debug(`[${correlationId}] Deactivating department: ${code}`); this.logger.debug(`[${correlationId}] Deactivating department: ${identifier}`);
const department = await this.departmentsService.findByCode(code); const department = await this.findDepartmentByIdentifier(identifier);
await this.departmentsService.deactivate(department.id); await this.departmentsService.deactivate(department.id);
this.logger.log(`[${correlationId}] Department deactivated: ${code}`); this.logger.log(`[${correlationId}] Department deactivated: ${identifier}`);
return { message: `Department ${code} has been deactivated` }; return { message: `Department ${department.code} has been deactivated` };
} }
} }

View File

@@ -55,9 +55,7 @@ export class DepartmentsService {
}); });
if (existingDepartment) { if (existingDepartment) {
throw new BadRequestException( throw new BadRequestException(`Department with code ${dto.code} already exists`);
`Department with code ${dto.code} already exists`,
);
} }
// Generate API key pair // Generate API key pair
@@ -84,7 +82,9 @@ export class DepartmentsService {
walletAddress: wallet.address, walletAddress: wallet.address,
}); });
this.logger.log(`Department created successfully: ${updatedDepartment.id} with wallet: ${wallet.address}`); this.logger.log(
`Department created successfully: ${updatedDepartment.id} with wallet: ${wallet.address}`,
);
return { return {
department: updatedDepartment, department: updatedDepartment,
@@ -99,12 +99,9 @@ export class DepartmentsService {
* @returns Promise<PaginatedResult<Department>> * @returns Promise<PaginatedResult<Department>>
*/ */
async findAll(query: PaginationDto): Promise<PaginatedResult<Department>> { async findAll(query: PaginationDto): Promise<PaginatedResult<Department>> {
this.logger.debug( this.logger.debug(`Fetching departments - page: ${query.page}, limit: ${query.limit}`);
`Fetching departments - page: ${query.page}, limit: ${query.limit}`,
);
const queryBuilder = this.departmentRepository.query() const queryBuilder = this.departmentRepository.query().orderBy('created_at', 'DESC');
.orderBy('created_at', 'DESC');
return await paginate(queryBuilder, query.page, query.limit); return await paginate(queryBuilder, query.page, query.limit);
} }
@@ -139,9 +136,7 @@ export class DepartmentsService {
}); });
if (!department) { if (!department) {
throw new NotFoundException( throw new NotFoundException(`Department with code ${code} not found`);
`Department with code ${code} not found`,
);
} }
return department; return department;
@@ -165,9 +160,7 @@ export class DepartmentsService {
}); });
if (existingDepartment) { if (existingDepartment) {
throw new ConflictException( throw new ConflictException(`Department with code ${dto.code} already exists`);
`Department with code ${dto.code} already exists`,
);
} }
} }
@@ -204,11 +197,7 @@ export class DepartmentsService {
* @param webhookSecret - Webhook secret for HMAC verification * @param webhookSecret - Webhook secret for HMAC verification
* @returns Promise<Department> * @returns Promise<Department>
*/ */
async updateWebhook( async updateWebhook(id: string, webhookUrl: string, webhookSecret?: string): Promise<Department> {
id: string,
webhookUrl: string,
webhookSecret?: string,
): Promise<Department> {
this.logger.debug(`Updating webhook for department: ${id}`); this.logger.debug(`Updating webhook for department: ${id}`);
const department = await this.findById(id); const department = await this.findById(id);
@@ -229,11 +218,7 @@ export class DepartmentsService {
* @param endDate - End date for statistics * @param endDate - End date for statistics
* @returns Promise<DepartmentStatsDto> * @returns Promise<DepartmentStatsDto>
*/ */
async getStats( async getStats(code: string, startDate?: Date, endDate?: Date): Promise<DepartmentStatsDto> {
code: string,
startDate?: Date,
endDate?: Date,
): Promise<DepartmentStatsDto> {
this.logger.debug( this.logger.debug(
`Fetching statistics for department: ${code} from ${startDate} to ${endDate}`, `Fetching statistics for department: ${code} from ${startDate} to ${endDate}`,
); );

View File

@@ -1,11 +1,4 @@
import { import { IsString, Matches, MinLength, IsOptional, IsUrl, IsEmail } from 'class-validator';
IsString,
Matches,
MinLength,
IsOptional,
IsUrl,
IsEmail,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class CreateDepartmentDto { export class CreateDepartmentDto {

View File

@@ -58,8 +58,7 @@ export class DepartmentStatsDto {
constructor() { constructor() {
if (this.totalApplicants > 0) { if (this.totalApplicants > 0) {
this.issueRate = this.issueRate = (this.totalCredentialsIssued / this.totalApplicants) * 100;
(this.totalCredentialsIssued / this.totalApplicants) * 100;
} }
} }
} }

View File

@@ -1,13 +1,6 @@
import { PartialType } from '@nestjs/swagger'; import { PartialType } from '@nestjs/swagger';
import { CreateDepartmentDto } from './create-department.dto'; import { CreateDepartmentDto } from './create-department.dto';
import { import { IsString, Matches, MinLength, IsOptional, IsUrl, IsEmail } from 'class-validator';
IsString,
Matches,
MinLength,
IsOptional,
IsUrl,
IsEmail,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) { export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {

View File

@@ -75,7 +75,12 @@ export class DocumentsController {
}) })
async uploadDocumentAlt( async uploadDocumentAlt(
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body() uploadDto: UploadDocumentDto & { requestId: string; documentType: string; description?: string }, @Body()
uploadDto: UploadDocumentDto & {
requestId: string;
documentType: string;
description?: string;
},
@CurrentUser() user: any, @CurrentUser() user: any,
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
): Promise<any> { ): Promise<any> {
@@ -99,7 +104,7 @@ export class DocumentsController {
uploadDto.requestId, uploadDto.requestId,
file, file,
{ docType: uploadDto.documentType, description: uploadDto.description }, { docType: uploadDto.documentType, description: uploadDto.description },
user.sub user.sub,
); );
const storagePath = `requests/${uploadDto.requestId}/${uploadDto.documentType}`; const storagePath = `requests/${uploadDto.requestId}/${uploadDto.documentType}`;
@@ -222,6 +227,10 @@ export class DocumentsController {
status: 404, status: 404,
description: 'Document not found', description: 'Document not found',
}) })
@ApiResponse({
status: 403,
description: 'Access denied',
})
async downloadDocument( async downloadDocument(
@Param('documentId') documentId: string, @Param('documentId') documentId: string,
@Query('version') version: string, @Query('version') version: string,
@@ -230,10 +239,27 @@ export class DocumentsController {
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
@Res() res: any, @Res() res: any,
): Promise<void> { ): Promise<void> {
this.logger.debug(`[${correlationId}] Downloading document: ${documentId}, version: ${version}, inline: ${inline}`); this.logger.debug(
`[${correlationId}] Downloading document: ${documentId}, version: ${version}, inline: ${inline}`,
);
// Validate documentId format
if (!documentId || documentId === 'undefined' || documentId === 'null') {
throw new BadRequestException('Invalid document ID');
}
// Check authorization // Check authorization
const canAccess = await this.documentsService.checkUserCanAccessDocument(user.sub, documentId); let canAccess: boolean;
try {
canAccess = await this.documentsService.checkUserCanAccessDocument(user.sub, documentId);
} catch (error: any) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`[${correlationId}] Error checking document access: ${error.message}`);
throw error;
}
if (!canAccess) { if (!canAccess) {
throw new ForbiddenException({ throw new ForbiddenException({
code: 'AUTH_008', code: 'AUTH_008',
@@ -241,16 +267,41 @@ export class DocumentsController {
}); });
} }
const versionNum = version ? parseInt(version) : undefined; const versionNum = version ? parseInt(version, 10) : undefined;
const { buffer, mimeType, fileName, fileSize, hash } = await this.documentsService.getFileContent(documentId, versionNum, user.sub);
// Validate version number if provided
if (version && (isNaN(versionNum) || versionNum < 1)) {
throw new BadRequestException('Invalid version number');
}
let fileContent: {
buffer: Buffer;
mimeType: string;
fileName: string;
fileSize: number;
hash: string;
};
try {
fileContent = await this.documentsService.getFileContent(documentId, versionNum, user.sub);
} catch (error: any) {
this.logger.error(
`[${correlationId}] Error retrieving file content for document ${documentId}: ${error.message}`,
);
throw error;
}
const { buffer, mimeType, fileName, fileSize, hash } = fileContent;
// Sanitize filename for Content-Disposition header
const sanitizedFileName = fileName.replace(/[^\w\s.-]/g, '_').replace(/\s+/g, '_');
const disposition = inline === 'true' ? 'inline' : 'attachment'; const disposition = inline === 'true' ? 'inline' : 'attachment';
res.set({ res.set({
'Content-Type': mimeType, 'Content-Type': mimeType || 'application/octet-stream',
'Content-Length': fileSize, 'Content-Length': fileSize || buffer.length,
'Content-Disposition': `${disposition}; filename="${fileName}"`, 'Content-Disposition': `${disposition}; filename="${sanitizedFileName}"`,
'X-File-Hash': hash, 'X-File-Hash': hash || '',
'Cache-Control': 'private, max-age=3600', 'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY', 'X-Frame-Options': 'DENY',
@@ -284,7 +335,7 @@ export class DocumentsController {
this.logger.debug(`[${correlationId}] Fetching versions for document: ${documentId}`); this.logger.debug(`[${correlationId}] Fetching versions for document: ${documentId}`);
const versions = await this.documentsService.getVersions(documentId); const versions = await this.documentsService.getVersions(documentId);
return versions.map((v) => ({ return versions.map(v => ({
id: v.id, id: v.id,
documentId: v.documentId, documentId: v.documentId,
version: v.version, version: v.version,
@@ -340,9 +391,7 @@ export class DocumentsController {
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@CorrelationId() correlationId: string, @CorrelationId() correlationId: string,
): Promise<DocumentResponseDto> { ): Promise<DocumentResponseDto> {
this.logger.debug( this.logger.debug(`[${correlationId}] Uploading new version for document: ${documentId}`);
`[${correlationId}] Uploading new version for document: ${documentId}`,
);
if (!file) { if (!file) {
throw new BadRequestException('File is required'); throw new BadRequestException('File is required');
@@ -405,10 +454,26 @@ export class DocumentsController {
this.logger.debug(`[${correlationId}] Fetching documents for request: ${requestId}`); this.logger.debug(`[${correlationId}] Fetching documents for request: ${requestId}`);
const documents = await this.documentsService.findByRequestId(requestId); const documents = await this.documentsService.findByRequestId(requestId);
return documents.map((d) => this.mapToResponseDto(d)); return documents.map(d => this.mapToResponseDto(d));
} }
private mapToResponseDto(document: any): DocumentResponseDto { private mapToResponseDto(document: any): DocumentResponseDto {
// Get file size and mimeType from the latest version if available
let fileSize: number | undefined;
let mimeType: string | undefined;
if (document.versions && document.versions.length > 0) {
// Sort versions by version number descending and get the latest
const latestVersion = document.versions.reduce(
(latest: any, v: any) => (v.version > (latest?.version || 0) ? v : latest),
null,
);
if (latestVersion) {
fileSize = parseInt(latestVersion.fileSize, 10) || undefined;
mimeType = latestVersion.mimeType;
}
}
return { return {
id: document.id, id: document.id,
requestId: document.requestId, requestId: document.requestId,
@@ -417,6 +482,8 @@ export class DocumentsController {
currentVersion: document.currentVersion, currentVersion: document.currentVersion,
currentHash: document.currentHash, currentHash: document.currentHash,
fileHash: document.currentHash, fileHash: document.currentHash,
fileSize,
mimeType,
minioBucket: document.minioBucket, minioBucket: document.minioBucket,
isActive: document.isActive, isActive: document.isActive,
downloadCount: document.downloadCount || 0, downloadCount: document.downloadCount || 0,

View File

@@ -6,10 +6,7 @@ import { MinioService } from './services/minio.service';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
@Module({ @Module({
imports: [ imports: [ConfigModule, AuditModule],
ConfigModule,
AuditModule,
],
controllers: [DocumentsController], controllers: [DocumentsController],
providers: [DocumentsService, MinioService], providers: [DocumentsService, MinioService],
exports: [DocumentsService, MinioService], exports: [DocumentsService, MinioService],

View File

@@ -24,9 +24,19 @@ export class DocumentsService {
private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; private readonly MAX_FILE_SIZE = 10 * 1024 * 1024;
private readonly ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; private readonly ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
private readonly ALLOWED_DOC_TYPES = [ private readonly ALLOWED_DOC_TYPES = [
'FLOOR_PLAN', 'PHOTOGRAPH', 'ID_PROOF', 'ADDRESS_PROOF', 'FLOOR_PLAN',
'NOC', 'LICENSE_COPY', 'OTHER', 'FIRE_SAFETY', 'HEALTH_CERT', 'PHOTOGRAPH',
'TAX_CLEARANCE', 'SITE_PLAN', 'BUILDING_PERMIT', 'BUSINESS_LICENSE' 'ID_PROOF',
'ADDRESS_PROOF',
'NOC',
'LICENSE_COPY',
'OTHER',
'FIRE_SAFETY',
'HEALTH_CERT',
'TAX_CLEARANCE',
'SITE_PLAN',
'BUILDING_PERMIT',
'BUSINESS_LICENSE',
]; ];
constructor( constructor(
@@ -56,7 +66,10 @@ export class DocumentsService {
.replace(/\\/g, '_'); .replace(/\\/g, '_');
} }
async checkUserCanAccessRequest(userId: string, requestId: string): Promise<{ canAccess: boolean; isAdmin: boolean }> { async checkUserCanAccessRequest(
userId: string,
requestId: string,
): Promise<{ canAccess: boolean; isAdmin: boolean }> {
const user = await this.userRepository.query().findById(userId); const user = await this.userRepository.query().findById(userId);
if (!user) { if (!user) {
return { canAccess: false, isAdmin: false }; return { canAccess: false, isAdmin: false };
@@ -67,7 +80,8 @@ export class DocumentsService {
return { canAccess: true, isAdmin: true }; return { canAccess: true, isAdmin: true };
} }
const request = await this.requestRepository.query() const request = await this.requestRepository
.query()
.findById(requestId) .findById(requestId)
.withGraphFetched('applicant'); .withGraphFetched('applicant');
@@ -83,7 +97,8 @@ export class DocumentsService {
// Check if user is from an assigned department // Check if user is from an assigned department
if (user.role === 'DEPARTMENT' && user.departmentId) { if (user.role === 'DEPARTMENT' && user.departmentId) {
const approvals = await this.approvalRepository.query() const approvals = await this.approvalRepository
.query()
.where({ request_id: requestId, department_id: user.departmentId }); .where({ request_id: requestId, department_id: user.departmentId });
if (approvals.length > 0) { if (approvals.length > 0) {
return { canAccess: true, isAdmin: false }; return { canAccess: true, isAdmin: false };
@@ -134,7 +149,9 @@ export class DocumentsService {
'uploaded-by': userId, 'uploaded-by': userId,
}); });
let document = await this.documentRepository.query().findOne({ request_id: requestId, doc_type: dto.docType }); const document = await this.documentRepository
.query()
.findOne({ request_id: requestId, doc_type: dto.docType });
let savedDocument; let savedDocument;
let currentVersion = 1; let currentVersion = 1;
@@ -206,7 +223,10 @@ export class DocumentsService {
async findById(id: string): Promise<Document> { async findById(id: string): Promise<Document> {
this.logger.debug(`Finding document: ${id}`); this.logger.debug(`Finding document: ${id}`);
const document = await this.documentRepository.query().findById(id).withGraphFetched('versions'); const document = await this.documentRepository
.query()
.findById(id)
.withGraphFetched('versions');
if (!document) { if (!document) {
throw new NotFoundException(`Document not found: ${id}`); throw new NotFoundException(`Document not found: ${id}`);
@@ -218,7 +238,8 @@ export class DocumentsService {
async findByRequestId(requestId: string): Promise<Document[]> { async findByRequestId(requestId: string): Promise<Document[]> {
this.logger.debug(`Finding documents for request: ${requestId}`); this.logger.debug(`Finding documents for request: ${requestId}`);
return await this.documentRepository.query() return await this.documentRepository
.query()
.where({ request_id: requestId, is_active: true }) .where({ request_id: requestId, is_active: true })
.withGraphFetched('versions') .withGraphFetched('versions')
.orderBy('created_at', 'DESC'); .orderBy('created_at', 'DESC');
@@ -229,7 +250,8 @@ export class DocumentsService {
await this.findById(documentId); await this.findById(documentId);
return await this.documentVersionRepository.query() return await this.documentVersionRepository
.query()
.where({ document_id: documentId }) .where({ document_id: documentId })
.orderBy('version', 'DESC'); .orderBy('version', 'DESC');
} }
@@ -251,7 +273,10 @@ export class DocumentsService {
return this.upload(requestId, file, { docType: document.docType }, userId); return this.upload(requestId, file, { docType: document.docType }, userId);
} }
async getDownloadUrl(documentId: string, version?: number): Promise<{ url: string; expiresAt: Date }> { async getDownloadUrl(
documentId: string,
version?: number,
): Promise<{ url: string; expiresAt: Date }> {
this.logger.debug(`Generating download URL for document: ${documentId}, version: ${version}`); this.logger.debug(`Generating download URL for document: ${documentId}, version: ${version}`);
const document = await this.findById(documentId); const document = await this.findById(documentId);
@@ -259,13 +284,16 @@ export class DocumentsService {
let targetVersion: DocumentVersion; let targetVersion: DocumentVersion;
if (version) { if (version) {
targetVersion = await this.documentVersionRepository.query().findOne({ document_id: documentId, version }); targetVersion = await this.documentVersionRepository
.query()
.findOne({ document_id: documentId, version });
if (!targetVersion) { if (!targetVersion) {
throw new NotFoundException(`Document version not found: ${version}`); throw new NotFoundException(`Document version not found: ${version}`);
} }
} else { } else {
targetVersion = await this.documentVersionRepository.query() targetVersion = await this.documentVersionRepository
.query()
.where({ document_id: documentId }) .where({ document_id: documentId })
.orderBy('version', 'DESC') .orderBy('version', 'DESC')
.first(); .first();
@@ -285,7 +313,11 @@ export class DocumentsService {
return { url, expiresAt }; return { url, expiresAt };
} }
async getFileContent(documentId: string, version?: number, userId?: string): Promise<{ async getFileContent(
documentId: string,
version?: number,
userId?: string,
): Promise<{
buffer: Buffer; buffer: Buffer;
mimeType: string; mimeType: string;
fileName: string; fileName: string;
@@ -299,13 +331,16 @@ export class DocumentsService {
let targetVersion: DocumentVersion; let targetVersion: DocumentVersion;
if (version) { if (version) {
targetVersion = await this.documentVersionRepository.query().findOne({ document_id: documentId, version }); targetVersion = await this.documentVersionRepository
.query()
.findOne({ document_id: documentId, version });
if (!targetVersion) { if (!targetVersion) {
throw new NotFoundException(`Document version not found: ${version}`); throw new NotFoundException(`Document version not found: ${version}`);
} }
} else { } else {
targetVersion = await this.documentVersionRepository.query() targetVersion = await this.documentVersionRepository
.query()
.where({ document_id: documentId }) .where({ document_id: documentId })
.orderBy('version', 'DESC') .orderBy('version', 'DESC')
.first(); .first();
@@ -316,38 +351,80 @@ export class DocumentsService {
} }
const bucket = document.minioBucket; const bucket = document.minioBucket;
const buffer = await this.minioService.getFile(bucket, targetVersion.minioPath);
// Validate that we have the required path information
if (!targetVersion.minioPath) {
this.logger.error(`Document version ${targetVersion.id} has no minioPath`);
throw new NotFoundException('Document file path not found');
}
if (!bucket) {
this.logger.error(`Document ${documentId} has no minioBucket`);
throw new NotFoundException('Document storage bucket not found');
}
let buffer: Buffer;
try {
buffer = await this.minioService.getFile(bucket, targetVersion.minioPath);
} catch (error: any) {
// Re-throw NotFoundException with more context
if (error instanceof NotFoundException) {
this.logger.error(
`File not found in storage for document ${documentId}: ${targetVersion.minioPath}`,
);
throw new NotFoundException(
`Document file not found in storage. The file may have been deleted or moved.`,
);
}
// Log and re-throw other errors
this.logger.error(`Failed to retrieve document ${documentId} from storage: ${error.message}`);
throw error;
}
// Track download // Track download
const currentCount = document.downloadCount || 0; const currentCount = document.downloadCount || 0;
await document.$query().patch({ try {
downloadCount: currentCount + 1, await document.$query().patch({
lastDownloadedAt: new Date().toISOString(), downloadCount: currentCount + 1,
}); lastDownloadedAt: new Date().toISOString(),
});
} catch (patchError: any) {
// Log but don't fail the download for tracking errors
this.logger.warn(
`Failed to update download tracking for document ${documentId}: ${patchError.message}`,
);
}
// Record audit log for download // Record audit log for download
if (userId) { if (userId) {
await this.auditService.record({ try {
entityType: 'REQUEST', await this.auditService.record({
entityId: document.requestId, entityType: 'REQUEST',
action: 'DOCUMENT_DOWNLOADED', entityId: document.requestId,
actorType: 'USER', action: 'DOCUMENT_DOWNLOADED',
actorId: userId, actorType: 'USER',
newValue: { actorId: userId,
documentId: documentId, newValue: {
filename: document.originalFilename, documentId: documentId,
version: targetVersion.version, filename: document.originalFilename,
performedBy: userId, version: targetVersion.version,
reason: `Document ${documentId} downloaded: ${document.originalFilename}`, performedBy: userId,
}, reason: `Document ${documentId} downloaded: ${document.originalFilename}`,
}); },
});
} catch (auditError: any) {
// Log but don't fail the download for audit errors
this.logger.warn(
`Failed to record audit log for document download ${documentId}: ${auditError.message}`,
);
}
} }
return { return {
buffer, buffer,
mimeType: targetVersion.mimeType, mimeType: targetVersion.mimeType || 'application/octet-stream',
fileName: document.originalFilename, fileName: document.originalFilename,
fileSize: parseInt(targetVersion.fileSize), fileSize: parseInt(targetVersion.fileSize) || buffer.length,
hash: targetVersion.hash, hash: targetVersion.hash,
}; };
} }
@@ -380,7 +457,8 @@ export class DocumentsService {
const document = await this.findById(documentId); const document = await this.findById(documentId);
const approvals = await this.approvalRepository.query() const approvals = await this.approvalRepository
.query()
.where({ .where({
request_id: document.requestId, request_id: document.requestId,
status: ApprovalStatus.APPROVED, status: ApprovalStatus.APPROVED,
@@ -418,7 +496,7 @@ export class DocumentsService {
private validateDocumentType(docType: string): void { private validateDocumentType(docType: string): void {
if (!this.ALLOWED_DOC_TYPES.includes(docType)) { if (!this.ALLOWED_DOC_TYPES.includes(docType)) {
throw new BadRequestException( throw new BadRequestException(
`Invalid document type: ${docType}. Allowed types: ${this.ALLOWED_DOC_TYPES.join(', ')}` `Invalid document type: ${docType}. Allowed types: ${this.ALLOWED_DOC_TYPES.join(', ')}`,
); );
} }
} }

View File

@@ -38,6 +38,18 @@ export class DocumentResponseDto {
}) })
fileHash?: string; fileHash?: string;
@ApiProperty({
description: 'File size in bytes',
required: false,
})
fileSize?: number;
@ApiProperty({
description: 'MIME type of the file',
required: false,
})
mimeType?: string;
@ApiProperty({ @ApiProperty({
description: 'MinIO bucket name', description: 'MinIO bucket name',
}) })

View File

@@ -2,16 +2,19 @@ import { IsString, IsIn, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export const ALLOWED_DOC_TYPES = [ export const ALLOWED_DOC_TYPES = [
'FIRE_SAFETY_CERTIFICATE', 'FLOOR_PLAN',
'BUILDING_PLAN', 'PHOTOGRAPH',
'PROPERTY_OWNERSHIP', 'ID_PROOF',
'INSPECTION_REPORT',
'POLLUTION_CERTIFICATE',
'ELECTRICAL_SAFETY_CERTIFICATE',
'STRUCTURAL_STABILITY_CERTIFICATE',
'IDENTITY_PROOF',
'ADDRESS_PROOF', 'ADDRESS_PROOF',
'NOC',
'LICENSE_COPY',
'OTHER', 'OTHER',
'FIRE_SAFETY',
'HEALTH_CERT',
'TAX_CLEARANCE',
'SITE_PLAN',
'BUILDING_PERMIT',
'BUSINESS_LICENSE',
]; ];
export class UploadDocumentDto { export class UploadDocumentDto {

View File

@@ -1,4 +1,9 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; import {
Injectable,
Logger,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as Minio from 'minio'; import * as Minio from 'minio';
@@ -16,10 +21,7 @@ export class MinioService {
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin'); const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin');
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin'); const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin');
this.defaultBucket = this.configService.get<string>( this.defaultBucket = this.configService.get<string>('MINIO_BUCKET', 'goa-gel-documents');
'MINIO_BUCKET',
'goa-gel-documents',
);
this.region = this.configService.get<string>('MINIO_REGION', 'us-east-1'); this.region = this.configService.get<string>('MINIO_REGION', 'us-east-1');
this.minioClient = new Minio.Client({ this.minioClient = new Minio.Client({
@@ -31,9 +33,7 @@ export class MinioService {
region: this.region, region: this.region,
}); });
this.logger.log( this.logger.log(`MinIO client initialized: ${endPoint}:${port}, bucket: ${this.defaultBucket}`);
`MinIO client initialized: ${endPoint}:${port}, bucket: ${this.defaultBucket}`,
);
} }
async uploadFile( async uploadFile(
@@ -61,18 +61,32 @@ export class MinioService {
} }
} }
async getSignedUrl( async getSignedUrl(bucket: string, path: string, expiresIn: number = 3600): Promise<string> {
bucket: string,
path: string,
expiresIn: number = 3600,
): Promise<string> {
this.logger.debug(`Generating signed URL for: ${bucket}/${path}`); this.logger.debug(`Generating signed URL for: ${bucket}/${path}`);
try { try {
// Verify file exists before generating URL
try {
await this.minioClient.statObject(bucket, path);
} catch (statError: any) {
if (
statError.code === 'NotFound' ||
statError.message?.includes('Not Found') ||
statError.code === 'NoSuchKey'
) {
this.logger.error(`File not found for signed URL: ${bucket}/${path}`);
throw new NotFoundException(`File not found in storage: ${path}`);
}
throw statError;
}
const url = await this.minioClient.presignedGetObject(bucket, path, expiresIn); const url = await this.minioClient.presignedGetObject(bucket, path, expiresIn);
this.logger.debug(`Signed URL generated successfully`); this.logger.debug(`Signed URL generated successfully`);
return url; return url;
} catch (error: any) { } catch (error: any) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to generate signed URL: ${error.message}`); this.logger.error(`Failed to generate signed URL: ${error.message}`);
throw new InternalServerErrorException('Failed to generate download URL'); throw new InternalServerErrorException('Failed to generate download URL');
} }
@@ -114,6 +128,14 @@ export class MinioService {
const stat = await this.minioClient.statObject(bucket, path); const stat = await this.minioClient.statObject(bucket, path);
return stat; return stat;
} catch (error: any) { } catch (error: any) {
if (
error.code === 'NotFound' ||
error.message?.includes('Not Found') ||
error.code === 'NoSuchKey'
) {
this.logger.error(`File not found: ${bucket}/${path}`);
throw new NotFoundException(`File not found in storage: ${path}`);
}
this.logger.error(`Failed to get file metadata: ${error.message}`); this.logger.error(`Failed to get file metadata: ${error.message}`);
throw new InternalServerErrorException('Failed to retrieve file metadata'); throw new InternalServerErrorException('Failed to retrieve file metadata');
} }
@@ -123,16 +145,45 @@ export class MinioService {
this.logger.debug(`Fetching file from MinIO: ${bucket}/${path}`); this.logger.debug(`Fetching file from MinIO: ${bucket}/${path}`);
try { try {
// First check if the bucket exists
const bucketExists = await this.minioClient.bucketExists(bucket);
if (!bucketExists) {
this.logger.error(`Bucket does not exist: ${bucket}`);
throw new NotFoundException(`Storage bucket not found: ${bucket}`);
}
// Check if the file exists before trying to retrieve it
try {
await this.minioClient.statObject(bucket, path);
} catch (statError: any) {
if (
statError.code === 'NotFound' ||
statError.message?.includes('Not Found') ||
statError.code === 'NoSuchKey'
) {
this.logger.error(`File not found in storage: ${bucket}/${path}`);
throw new NotFoundException(`File not found in storage: ${path}`);
}
throw statError;
}
const dataStream = await this.minioClient.getObject(bucket, path); const dataStream = await this.minioClient.getObject(bucket, path);
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
dataStream.on('data', (chunk) => chunks.push(chunk)); dataStream.on('data', chunk => chunks.push(chunk));
dataStream.on('end', () => resolve(Buffer.concat(chunks))); dataStream.on('end', () => resolve(Buffer.concat(chunks)));
dataStream.on('error', reject); dataStream.on('error', err => {
this.logger.error(`Stream error while fetching file: ${err.message}`);
reject(new InternalServerErrorException('Failed to retrieve file from storage'));
});
}); });
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to get file: ${error.message}`); // Re-throw NotFoundException as-is
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to get file: ${error.message}`, error.stack);
throw new InternalServerErrorException('Failed to retrieve file from storage'); throw new InternalServerErrorException('Failed to retrieve file from storage');
} }
} }
@@ -163,4 +214,39 @@ export class MinioService {
getDefaultBucket(): string { getDefaultBucket(): string {
return this.defaultBucket; return this.defaultBucket;
} }
/**
* Check if MinIO service is healthy and can be reached
*/
async isHealthy(): Promise<boolean> {
try {
// Try to list buckets as a health check
await this.minioClient.listBuckets();
return true;
} catch (error: any) {
this.logger.error(`MinIO health check failed: ${error.message}`);
return false;
}
}
/**
* Check if a specific file exists in storage
*/
async fileExists(bucket: string, path: string): Promise<boolean> {
try {
await this.minioClient.statObject(bucket, path);
return true;
} catch (error: any) {
if (
error.code === 'NotFound' ||
error.message?.includes('Not Found') ||
error.code === 'NoSuchKey'
) {
return false;
}
// For other errors, log and return false
this.logger.error(`Error checking file existence: ${error.message}`);
return false;
}
}
} }

View File

@@ -1,14 +1,5 @@
import { import { Controller, Get, Inject, Logger } from '@nestjs/common';
Controller, import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
Get,
Inject,
Logger,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { KNEX_CONNECTION } from '../../database/database.module'; import { KNEX_CONNECTION } from '../../database/database.module';
import type Knex from 'knex'; import type Knex from 'knex';

View File

@@ -1,4 +1,17 @@
import { IsString, IsEnum, IsObject, ValidateNested, IsUUID, IsOptional, ValidateIf, MinLength, Matches, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator'; import {
IsString,
IsEnum,
IsObject,
ValidateNested,
IsUUID,
IsOptional,
ValidateIf,
MinLength,
Matches,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { RequestType } from '../enums/request-type.enum'; import { RequestType } from '../enums/request-type.enum';

View File

@@ -82,7 +82,7 @@ export class RequestsController {
this.logger.debug(`[${correlationId}] Creating new request for user: ${user.email}`); this.logger.debug(`[${correlationId}] Creating new request for user: ${user.email}`);
// Only citizens/applicants can create requests (CITIZEN is the database role name) // Only citizens/applicants can create requests (CITIZEN is the database role name)
if (user.role !== UserRole.APPLICANT && user.role !== 'CITIZEN' as any) { if (user.role !== UserRole.APPLICANT && user.role !== ('CITIZEN' as any)) {
throw new ForbiddenException('Only citizens can create license requests'); throw new ForbiddenException('Only citizens can create license requests');
} }
@@ -153,7 +153,7 @@ export class RequestsController {
this.logger.debug(`[${correlationId}] Fetching license requests`); this.logger.debug(`[${correlationId}] Fetching license requests`);
// Citizens can only see their own requests // Citizens can only see their own requests
if (user.role === UserRole.APPLICANT || user.role === 'CITIZEN' as any) { if (user.role === UserRole.APPLICANT || user.role === ('CITIZEN' as any)) {
const applicant = await this.applicantModel.query().findOne({ email: user.email }); const applicant = await this.applicantModel.query().findOne({ email: user.email });
if (!applicant) { if (!applicant) {
throw new BadRequestException('No applicant profile found for user'); throw new BadRequestException('No applicant profile found for user');
@@ -166,15 +166,15 @@ export class RequestsController {
query.departmentCode = user.departmentCode; query.departmentCode = user.departmentCode;
} }
const { results, total } = await this.requestsService.findAll(query); const { data, total } = await this.requestsService.findAll(query);
const totalPages = Math.ceil(total / query.limit);
return { return {
data: results.map((r) => this.mapToResponseDto(r)), data: data.map(r => this.mapToResponseDto(r)),
meta: { total,
total, page: query.page,
page: query.page, limit: query.limit,
limit: query.limit, totalPages,
totalPages: Math.ceil(total / query.limit), hasNextPage: query.page < totalPages,
},
}; };
} }
@@ -206,9 +206,9 @@ export class RequestsController {
this.logger.debug(`[${correlationId}] Fetching pending requests`); this.logger.debug(`[${correlationId}] Fetching pending requests`);
const deptCode = 'FIRE_SAFETY'; const deptCode = 'FIRE_SAFETY';
const { results, total } = await this.requestsService.findPendingForDepartment(deptCode, query); const { data, total } = await this.requestsService.findPendingForDepartment(deptCode, query);
return { return {
data: results.map((r) => this.mapToResponseDto(r)), data: data.map(r => this.mapToResponseDto(r)),
meta: { meta: {
total, total,
page: query.page, page: query.page,
@@ -246,7 +246,7 @@ export class RequestsController {
const request = await this.requestsService.findById(id); const request = await this.requestsService.findById(id);
// Citizens can only view their own requests // Citizens can only view their own requests
if (user.role === UserRole.APPLICANT || user.role === 'CITIZEN' as any) { if (user.role === UserRole.APPLICANT || user.role === ('CITIZEN' as any)) {
const applicant = await this.applicantModel.query().findOne({ email: user.email }); const applicant = await this.applicantModel.query().findOne({ email: user.email });
if (!applicant) { if (!applicant) {
throw new ForbiddenException('No applicant profile found for user'); throw new ForbiddenException('No applicant profile found for user');
@@ -259,9 +259,21 @@ export class RequestsController {
// Department users can only view requests assigned to their department // Department users can only view requests assigned to their department
if (user.role === UserRole.DEPARTMENT && user.departmentCode) { if (user.role === UserRole.DEPARTMENT && user.departmentCode) {
const approvals = (request as any).approvals; const approvals = (request as any).approvals;
const hasApproval = Array.isArray(approvals) && approvals.some((a: any) => // Check if any approval is for this department
(a as any).department?.code === user.departmentCode // Try department.code first (if relation loaded), fall back to departmentId lookup
); const hasApproval =
Array.isArray(approvals) &&
approvals.some((a: any) => {
// Primary check: department relation loaded with code
if ((a as any).department?.code === user.departmentCode) {
return true;
}
// Secondary check: compare departmentId with user's department sub (which is the department ID)
if (a.departmentId && a.departmentId === user.sub) {
return true;
}
return false;
});
if (!hasApproval) { if (!hasApproval) {
throw new ForbiddenException('You can only view requests assigned to your department'); throw new ForbiddenException('You can only view requests assigned to your department');
} }
@@ -475,7 +487,8 @@ export class RequestsController {
if (Array.isArray(approvals)) { if (Array.isArray(approvals)) {
const pendingApproval = approvals.find((a: any) => a.status === 'PENDING'); const pendingApproval = approvals.find((a: any) => a.status === 'PENDING');
if (pendingApproval) { if (pendingApproval) {
assignedDepartment = (pendingApproval as any).department?.code || pendingApproval.departmentId; assignedDepartment =
(pendingApproval as any).department?.code || pendingApproval.departmentId;
} }
} }
@@ -550,7 +563,8 @@ export class RequestsController {
if (Array.isArray(approvals)) { if (Array.isArray(approvals)) {
const pendingApproval = approvals.find((a: any) => a.status === 'PENDING'); const pendingApproval = approvals.find((a: any) => a.status === 'PENDING');
if (pendingApproval) { if (pendingApproval) {
assignedDepartment = (pendingApproval as any).department?.code || pendingApproval.departmentId; assignedDepartment =
(pendingApproval as any).department?.code || pendingApproval.departmentId;
} }
} }
@@ -571,44 +585,50 @@ export class RequestsController {
formData: metadata, formData: metadata,
blockchainTxHash: request.blockchainTxHash, blockchainTxHash: request.blockchainTxHash,
tokenId: request.tokenId, tokenId: request.tokenId,
documents: (request.documents as any)?.map((d: any) => ({ documents:
id: d.id, (request.documents as any)?.map((d: any) => ({
docType: d.docType, id: d.id,
originalFilename: d.originalFilename, docType: d.docType,
currentVersion: d.currentVersion, originalFilename: d.originalFilename,
currentHash: d.currentHash, currentVersion: d.currentVersion,
minioBucket: d.minioBucket, currentHash: d.currentHash,
isActive: d.isActive, minioBucket: d.minioBucket,
createdAt: d.createdAt, isActive: d.isActive,
updatedAt: d.updatedAt, createdAt: d.createdAt,
})) || [], updatedAt: d.updatedAt,
approvals: (request.approvals as any)?.map((a: any) => ({ })) || [],
id: a.id, approvals:
departmentId: a.departmentId, (request.approvals as any)?.map((a: any) => ({
status: a.status, id: a.id,
remarks: a.remarks, departmentId: a.departmentId,
reviewedDocuments: a.reviewedDocuments, status: a.status,
createdAt: a.createdAt, remarks: a.remarks,
updatedAt: a.updatedAt, reviewedDocuments: a.reviewedDocuments,
invalidatedAt: a.invalidatedAt, createdAt: a.createdAt,
invalidationReason: a.invalidationReason, updatedAt: a.updatedAt,
})) || [], invalidatedAt: a.invalidatedAt,
invalidationReason: a.invalidationReason,
})) || [],
createdAt: request.createdAt, createdAt: request.createdAt,
updatedAt: request.updatedAt, updatedAt: request.updatedAt,
submittedAt: request.submittedAt, submittedAt: request.submittedAt,
approvedAt: request.approvedAt, approvedAt: request.approvedAt,
workflow: workflow ? { workflow: workflow
id: workflow.id, ? {
code: workflow.workflowType, id: workflow.id,
name: workflow.name, code: workflow.workflowType,
steps: workflow.definition?.steps || [], name: workflow.name,
} : undefined, steps: workflow.definition?.steps || [],
applicant: applicant ? { }
id: applicant.id, : undefined,
email: applicant.email, applicant: applicant
name: applicant.name, ? {
walletAddress: applicant.walletAddress || '', id: applicant.id,
} : undefined, email: applicant.email,
name: applicant.name,
walletAddress: applicant.walletAddress || '',
}
: undefined,
}; };
return plainToInstance(RequestDetailResponseDto, result, { excludeExtraneousValues: false }); return plainToInstance(RequestDetailResponseDto, result, { excludeExtraneousValues: false });

View File

@@ -48,7 +48,9 @@ export class RequestsService {
// Handle workflow lookup by code if provided // Handle workflow lookup by code if provided
let workflowId = dto.workflowId; let workflowId = dto.workflowId;
if (!workflowId && dto.workflowCode) { if (!workflowId && dto.workflowCode) {
const workflow = await this.requestRepository.knex().table('workflows') const workflow = await this.requestRepository
.knex()
.table('workflows')
.where({ workflow_type: dto.workflowCode, is_active: true }) .where({ workflow_type: dto.workflowCode, is_active: true })
.first(); .first();
@@ -74,7 +76,18 @@ export class RequestsService {
// Add any remaining top-level fields that aren't part of the core DTO as metadata // Add any remaining top-level fields that aren't part of the core DTO as metadata
for (const [key, value] of Object.entries(dto)) { for (const [key, value] of Object.entries(dto)) {
if (!['workflowCode', 'workflowId', 'requestType', 'metadata', 'tokenId', 'applicantName', 'applicantPhone', 'businessName'].includes(key)) { if (
![
'workflowCode',
'workflowId',
'requestType',
'metadata',
'tokenId',
'applicantName',
'applicantPhone',
'businessName',
].includes(key)
) {
metadata[key] = value; metadata[key] = value;
} }
} }
@@ -91,7 +104,9 @@ export class RequestsService {
// Load workflow relation for response // Load workflow relation for response
await savedRequest.$fetchGraph('workflow'); await savedRequest.$fetchGraph('workflow');
this.logger.log(`License request created: ${savedRequest.id} (${savedRequest.requestNumber})`); this.logger.log(
`License request created: ${savedRequest.id} (${savedRequest.requestNumber})`,
);
return savedRequest; return savedRequest;
} catch (error: any) { } catch (error: any) {
@@ -169,20 +184,24 @@ export class RequestsService {
// Map camelCase to snake_case for database columns // Map camelCase to snake_case for database columns
const sortMap: Record<string, string> = { const sortMap: Record<string, string> = {
'createdAt': 'created_at', createdAt: 'created_at',
'updatedAt': 'updated_at', updatedAt: 'updated_at',
'requestNumber': 'request_number', requestNumber: 'request_number',
'status': 'status', status: 'status',
}; };
queryBuilder.orderBy(sortMap[safeSort] || 'created_at', sortOrder.toUpperCase() as 'ASC' | 'DESC'); queryBuilder.orderBy(
sortMap[safeSort] || 'created_at',
sortOrder.toUpperCase() as 'ASC' | 'DESC',
);
// Fetch related data for response mapping // Fetch related data for response mapping
// When filtering by department, only load approvals for that department // When filtering by department, only load approvals for that department
if (departmentCode) { if (departmentCode) {
queryBuilder.withGraphFetched('[workflow, approvals(pendingForDept).department, workflowState]') queryBuilder
.withGraphFetched('[workflow, approvals(pendingForDept).department, workflowState]')
.modifiers({ .modifiers({
pendingForDept: (builder) => { pendingForDept: builder => {
builder builder
.where('approvals.status', ApprovalStatus.PENDING) .where('approvals.status', ApprovalStatus.PENDING)
.joinRelated('department') .joinRelated('department')
@@ -193,16 +212,20 @@ export class RequestsService {
queryBuilder.withGraphFetched('[workflow, approvals.department, workflowState]'); queryBuilder.withGraphFetched('[workflow, approvals.department, workflowState]');
} }
return queryBuilder.page(page - 1, limit); const result = await queryBuilder.page(page - 1, limit);
return { data: result.results, total: result.total };
} }
async findById(id: string): Promise<LicenseRequest> { async findById(id: string): Promise<LicenseRequest> {
this.logger.debug(`Finding license request: ${id}`); this.logger.debug(`Finding license request: ${id}`);
const request = await this.requestRepository.query() const request = await this.requestRepository
.query()
.select('license_requests.*') .select('license_requests.*')
.findById(id) .findById(id)
.withGraphFetched('[applicant, workflow, documents, documents.versions, approvals.department, workflowState]'); .withGraphFetched(
'[applicant, workflow, documents, documents.versions, approvals.department, workflowState]',
);
if (!request) { if (!request) {
throw new NotFoundException(`License request not found: ${id}`); throw new NotFoundException(`License request not found: ${id}`);
@@ -214,7 +237,8 @@ export class RequestsService {
async findByRequestNumber(requestNumber: string): Promise<LicenseRequest> { async findByRequestNumber(requestNumber: string): Promise<LicenseRequest> {
this.logger.debug(`Finding license request by number: ${requestNumber}`); this.logger.debug(`Finding license request by number: ${requestNumber}`);
const request = await this.requestRepository.query() const request = await this.requestRepository
.query()
.findOne({ requestNumber }) .findOne({ requestNumber })
.withGraphFetched('[applicant, workflow, documents, approvals]'); .withGraphFetched('[applicant, workflow, documents, approvals]');
@@ -233,7 +257,8 @@ export class RequestsService {
const { page = 1, limit = 20 } = query; const { page = 1, limit = 20 } = query;
const requests = await this.requestRepository.query() const requests = await this.requestRepository
.query()
.joinRelated('approvals.department') .joinRelated('approvals.department')
.where('approvals.status', ApprovalStatus.PENDING) .where('approvals.status', ApprovalStatus.PENDING)
.where('department.code', deptCode) .where('department.code', deptCode)
@@ -241,9 +266,11 @@ export class RequestsService {
.page(page - 1, limit) .page(page - 1, limit)
.orderBy('created_at', 'DESC'); .orderBy('created_at', 'DESC');
this.logger.debug(`Found ${requests.results.length} pending requests for department ${deptCode}`); this.logger.debug(
`Found ${requests.results.length} pending requests for department ${deptCode}`,
);
return requests as PaginatedResult<LicenseRequest>; return { data: requests.results, total: requests.total };
} }
async submit(id: string): Promise<LicenseRequest> { async submit(id: string): Promise<LicenseRequest> {
@@ -259,7 +286,8 @@ export class RequestsService {
[LicenseRequestStatus.REJECTED]: 'Request already rejected', [LicenseRequestStatus.REJECTED]: 'Request already rejected',
[LicenseRequestStatus.CANCELLED]: 'Request cancelled', [LicenseRequestStatus.CANCELLED]: 'Request cancelled',
}; };
const message = statusMessages[request.status as LicenseRequestStatus] || const message =
statusMessages[request.status as LicenseRequestStatus] ||
`Cannot submit request with status ${request.status}`; `Cannot submit request with status ${request.status}`;
throw new BadRequestException(message); throw new BadRequestException(message);
} }
@@ -287,7 +315,8 @@ export class RequestsService {
if (firstStage && firstStage.requiredApprovals?.length > 0) { if (firstStage && firstStage.requiredApprovals?.length > 0) {
// Create approval records for each department in the first stage // Create approval records for each department in the first stage
for (const deptApproval of firstStage.requiredApprovals) { for (const deptApproval of firstStage.requiredApprovals) {
const department = await this.departmentRepository.query() const department = await this.departmentRepository
.query()
.findOne({ code: deptApproval.departmentCode }); .findOne({ code: deptApproval.departmentCode });
if (department) { if (department) {
@@ -298,15 +327,16 @@ export class RequestsService {
isActive: true, isActive: true,
}); });
} else { } else {
this.logger.warn(`Department ${deptApproval.departmentCode} not found, skipping approval creation`); this.logger.warn(
`Department ${deptApproval.departmentCode} not found, skipping approval creation`,
);
} }
} }
} }
// Generate a mock blockchain transaction hash (in production, this would be from actual blockchain) // Generate a mock blockchain transaction hash (in production, this would be from actual blockchain)
const mockTxHash = '0x' + Array.from({ length: 64 }, () => const mockTxHash =
Math.floor(Math.random() * 16).toString(16) '0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
).join('');
await request.$query().patch({ await request.$query().patch({
status: LicenseRequestStatus.SUBMITTED, status: LicenseRequestStatus.SUBMITTED,
@@ -339,15 +369,21 @@ export class RequestsService {
throw new BadRequestException('Request is already cancelled'); throw new BadRequestException('Request is already cancelled');
} }
if ([LicenseRequestStatus.APPROVED, LicenseRequestStatus.REJECTED].includes(request.status as LicenseRequestStatus)) { if (
[LicenseRequestStatus.APPROVED, LicenseRequestStatus.REJECTED].includes(
request.status as LicenseRequestStatus,
)
) {
throw new BadRequestException(`Cannot cancel request with status ${request.status}`); throw new BadRequestException(`Cannot cancel request with status ${request.status}`);
} }
// Generate blockchain transaction hash for submitted requests // Generate blockchain transaction hash for submitted requests
const isSubmitted = request.status === LicenseRequestStatus.SUBMITTED || const isSubmitted =
request.status === LicenseRequestStatus.IN_REVIEW; request.status === LicenseRequestStatus.SUBMITTED ||
request.status === LicenseRequestStatus.IN_REVIEW;
const cancellationTxHash = isSubmitted const cancellationTxHash = isSubmitted
? '0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('') ? '0x' +
Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
: undefined; : undefined;
const metadataUpdate: any = { const metadataUpdate: any = {
@@ -390,13 +426,13 @@ export class RequestsService {
const request = await this.findById(id); const request = await this.findById(id);
const metadataPatch = {}; const metadataPatch = {};
if(dto.businessName !== undefined) { if (dto.businessName !== undefined) {
metadataPatch['businessName'] = dto.businessName; metadataPatch['businessName'] = dto.businessName;
} }
if(dto.description !== undefined) { if (dto.description !== undefined) {
metadataPatch['description'] = dto.description; metadataPatch['description'] = dto.description;
} }
if(dto.metadata !== undefined) { if (dto.metadata !== undefined) {
Object.assign(metadataPatch, dto.metadata); Object.assign(metadataPatch, dto.metadata);
} }
@@ -453,7 +489,8 @@ export class RequestsService {
}); });
} }
const approvals = await this.approvalRepository.query() const approvals = await this.approvalRepository
.query()
.where({ requestId: request.id }) .where({ requestId: request.id })
.withGraphFetched('department') .withGraphFetched('department')
.orderBy('updated_at', 'DESC'); .orderBy('updated_at', 'DESC');
@@ -515,10 +552,12 @@ export class RequestsService {
'INSPECTION_REPORT', 'INSPECTION_REPORT',
]; ];
const documents = await this.documentRepository.query().where({ request_id: id, is_active: true }); const documents = await this.documentRepository
.query()
.where({ request_id: id, is_active: true });
const uploadedDocTypes = documents.map((d) => d.docType); const uploadedDocTypes = documents.map(d => d.docType);
const missing = requiredDocTypes.filter((dt) => !uploadedDocTypes.includes(dt)); const missing = requiredDocTypes.filter(dt => !uploadedDocTypes.includes(dt));
const valid = missing.length === 0; const valid = missing.length === 0;
@@ -534,7 +573,10 @@ export class RequestsService {
const currentStatus = request.status as LicenseRequestStatus; const currentStatus = request.status as LicenseRequestStatus;
const validTransitions: Record<LicenseRequestStatus, LicenseRequestStatus[]> = { const validTransitions: Record<LicenseRequestStatus, LicenseRequestStatus[]> = {
[LicenseRequestStatus.DRAFT]: [LicenseRequestStatus.SUBMITTED, LicenseRequestStatus.CANCELLED], [LicenseRequestStatus.DRAFT]: [
LicenseRequestStatus.SUBMITTED,
LicenseRequestStatus.CANCELLED,
],
[LicenseRequestStatus.SUBMITTED]: [ [LicenseRequestStatus.SUBMITTED]: [
LicenseRequestStatus.IN_REVIEW, LicenseRequestStatus.IN_REVIEW,
LicenseRequestStatus.CANCELLED, LicenseRequestStatus.CANCELLED,
@@ -555,9 +597,7 @@ export class RequestsService {
}; };
if (!validTransitions[currentStatus]?.includes(newStatus as LicenseRequestStatus)) { if (!validTransitions[currentStatus]?.includes(newStatus as LicenseRequestStatus)) {
throw new BadRequestException( throw new BadRequestException(`Cannot transition from ${currentStatus} to ${newStatus}`);
`Cannot transition from ${currentStatus} to ${newStatus}`,
);
} }
const patch: Partial<LicenseRequest> = { status: newStatus as LicenseRequestStatus }; const patch: Partial<LicenseRequest> = { status: newStatus as LicenseRequestStatus };

View File

@@ -19,9 +19,7 @@ export class WebhooksService {
async register(departmentId: string, dto: CreateWebhookDto): Promise<Webhook> { async register(departmentId: string, dto: CreateWebhookDto): Promise<Webhook> {
try { try {
this.logger.debug( this.logger.debug(`Registering webhook for department: ${departmentId}, URL: ${dto.url}`);
`Registering webhook for department: ${departmentId}, URL: ${dto.url}`,
);
const secret = crypto.randomBytes(32).toString('hex'); const secret = crypto.randomBytes(32).toString('hex');
@@ -43,7 +41,8 @@ export class WebhooksService {
async findAll(departmentId: string): Promise<Webhook[]> { async findAll(departmentId: string): Promise<Webhook[]> {
try { try {
return await this.webhookRepository.query() return await this.webhookRepository
.query()
.where({ departmentId }) .where({ departmentId })
.orderBy('created_at', 'DESC'); .orderBy('created_at', 'DESC');
} catch (error) { } catch (error) {
@@ -52,6 +51,17 @@ export class WebhooksService {
} }
} }
async findAllPaginated(pagination: PaginationDto): Promise<PaginatedResult<Webhook>> {
try {
const query = this.webhookRepository.query().orderBy('created_at', 'DESC');
return await paginate(query, pagination.page, pagination.limit);
} catch (error) {
this.logger.error('Failed to find all webhooks', error);
throw error;
}
}
async findById(id: string): Promise<Webhook> { async findById(id: string): Promise<Webhook> {
try { try {
const webhook = await this.webhookRepository.query().findById(id); const webhook = await this.webhookRepository.query().findById(id);
@@ -161,11 +171,15 @@ export class WebhooksService {
} }
} }
async getLogs(webhookId: string, pagination: PaginationDto): Promise<PaginatedResult<WebhookLog>> { async getLogs(
webhookId: string,
pagination: PaginationDto,
): Promise<PaginatedResult<WebhookLog>> {
try { try {
await this.findById(webhookId); await this.findById(webhookId);
const query = this.webhookLogRepository.query() const query = this.webhookLogRepository
.query()
.where({ webhookId }) .where({ webhookId })
.orderBy('created_at', 'DESC'); .orderBy('created_at', 'DESC');
@@ -183,10 +197,7 @@ export class WebhooksService {
verifySignature(payload: object, signature: string, secret: string): boolean { verifySignature(payload: object, signature: string, secret: string): boolean {
const expectedSignature = this.generateSignature(payload, secret); const expectedSignature = this.generateSignature(payload, secret);
return crypto.timingSafeEqual( return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} }
async logWebhookAttempt( async logWebhookAttempt(

View File

@@ -40,6 +40,31 @@ export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {} constructor(private readonly webhooksService: WebhooksService) {}
@Get()
@ApiOperation({
summary: 'List all webhooks',
description: 'Get all webhook subscriptions with pagination',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 20)',
})
@ApiResponse({
status: 200,
description: 'Paginated list of all webhooks',
})
async findAllWebhooks(@Query() pagination: PaginationDto) {
return this.webhooksService.findAllPaginated(pagination);
}
@Post(':departmentId') @Post(':departmentId')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ @ApiOperation({
@@ -54,10 +79,7 @@ export class WebhooksController {
}) })
@ApiResponse({ status: 400, description: 'Invalid webhook data' }) @ApiResponse({ status: 400, description: 'Invalid webhook data' })
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
async register( async register(@Param('departmentId') departmentId: string, @Body() dto: CreateWebhookDto) {
@Param('departmentId') departmentId: string,
@Body() dto: CreateWebhookDto,
) {
this.logger.debug(`Registering webhook for department: ${departmentId}`); this.logger.debug(`Registering webhook for department: ${departmentId}`);
return this.webhooksService.register(departmentId, dto); return this.webhooksService.register(departmentId, dto);
} }
@@ -148,14 +170,21 @@ export class WebhooksController {
description: 'Get paginated delivery logs for a specific webhook', description: 'Get paginated delivery logs for a specific webhook',
}) })
@ApiParam({ name: 'id', description: 'Webhook ID (UUID)' }) @ApiParam({ name: 'id', description: 'Webhook ID (UUID)' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) @ApiQuery({
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' }) name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Items per page (default: 20)',
})
@ApiResponse({ status: 200, description: 'Webhook delivery logs' }) @ApiResponse({ status: 200, description: 'Webhook delivery logs' })
@ApiResponse({ status: 404, description: 'Webhook not found' }) @ApiResponse({ status: 404, description: 'Webhook not found' })
async getLogs( async getLogs(@Param('id') id: string, @Query() pagination: PaginationDto) {
@Param('id') id: string,
@Query() pagination: PaginationDto,
) {
return this.webhooksService.getLogs(id, pagination); return this.webhooksService.getLogs(id, pagination);
} }
} }

View File

@@ -1,9 +1,4 @@
import { import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
IsString,
IsOptional,
IsArray,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { WorkflowStageDto } from './workflow-stage.dto'; import { WorkflowStageDto } from './workflow-stage.dto';

View File

@@ -1,9 +1,4 @@
import { import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
IsString,
IsOptional,
IsArray,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { WorkflowStageDto } from './workflow-stage.dto'; import { WorkflowStageDto } from './workflow-stage.dto';

View File

@@ -1,11 +1,4 @@
import { import { IsString, IsNumber, IsEnum, IsArray, ValidateNested, IsOptional } from 'class-validator';
IsString,
IsNumber,
IsEnum,
IsArray,
ValidateNested,
IsOptional,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ExecutionType } from '../enums/execution-type.enum'; import { ExecutionType } from '../enums/execution-type.enum';
import { CompletionCriteria } from '../enums/completion-criteria.enum'; import { CompletionCriteria } from '../enums/completion-criteria.enum';

View File

@@ -11,10 +11,7 @@ import {
DocumentUpdateResult, DocumentUpdateResult,
StageCompletionCheck, StageCompletionCheck,
} from '../interfaces/workflow-process-result.interface'; } from '../interfaces/workflow-process-result.interface';
import { import { WorkflowDefinition, WorkflowStage } from '../interfaces/workflow-definition.interface';
WorkflowDefinition,
WorkflowStage,
} from '../interfaces/workflow-definition.interface';
import { WorkflowAction } from '../enums/workflow-action.enum'; import { WorkflowAction } from '../enums/workflow-action.enum';
import { CompletionCriteria } from '../enums/completion-criteria.enum'; import { CompletionCriteria } from '../enums/completion-criteria.enum';
import { RejectionHandling } from '../enums/rejection-handling.enum'; import { RejectionHandling } from '../enums/rejection-handling.enum';
@@ -35,17 +32,14 @@ export class WorkflowExecutorService {
/** /**
* Initialize workflow state for a new request * Initialize workflow state for a new request
*/ */
async initializeWorkflow( async initializeWorkflow(requestId: string, workflowId: string): Promise<WorkflowState> {
requestId: string,
workflowId: string,
): Promise<WorkflowState> {
const workflow = await this.workflowRepository.query().findById(workflowId); const workflow = await this.workflowRepository.query().findById(workflowId);
if (!workflow || !workflow.isActive) { if (!workflow || !workflow.isActive) {
throw new NotFoundException(`Active workflow ${workflowId} not found`); throw new NotFoundException(`Active workflow ${workflowId} not found`);
} }
const definition = (workflow.definition as any) as WorkflowDefinition; const definition = workflow.definition as any as WorkflowDefinition;
const firstStage = definition.stages[0]; const firstStage = definition.stages[0];
if (!firstStage) { if (!firstStage) {
@@ -53,7 +47,7 @@ export class WorkflowExecutorService {
} }
const pendingApprovals: PendingApproval[] = (firstStage.requiredApprovals || []).map( const pendingApprovals: PendingApproval[] = (firstStage.requiredApprovals || []).map(
(approval) => ({ approval => ({
departmentCode: approval.departmentCode, departmentCode: approval.departmentCode,
departmentName: approval.departmentName, departmentName: approval.departmentName,
approvalId: '', // Will be populated when approval records are created approvalId: '', // Will be populated when approval records are created
@@ -88,9 +82,13 @@ export class WorkflowExecutorService {
* Get workflow state * Get workflow state
*/ */
async getWorkflowState(requestId: string): Promise<WorkflowState | null> { async getWorkflowState(requestId: string): Promise<WorkflowState | null> {
const stateEntity = await (this.workflowStateRepository.constructor as typeof WorkflowStateModel).query().findOne({ const stateEntity = await (
requestId, this.workflowStateRepository.constructor as typeof WorkflowStateModel
}); )
.query()
.findOne({
requestId,
});
return stateEntity ? stateEntity.state : null; return stateEntity ? stateEntity.state : null;
} }
@@ -99,9 +97,13 @@ export class WorkflowExecutorService {
* Save workflow state * Save workflow state
*/ */
async saveWorkflowState(state: WorkflowState): Promise<void> { async saveWorkflowState(state: WorkflowState): Promise<void> {
const stateEntity = await (this.workflowStateRepository.constructor as typeof WorkflowStateModel).query().findOne({ const stateEntity = await (
requestId: state.requestId, this.workflowStateRepository.constructor as typeof WorkflowStateModel
}); )
.query()
.findOne({
requestId: state.requestId,
});
if (stateEntity) { if (stateEntity) {
await stateEntity.$query().patch({ state }); await stateEntity.$query().patch({ state });
@@ -133,14 +135,10 @@ export class WorkflowExecutorService {
} }
// Update pending approval status // Update pending approval status
const pendingApproval = state.pendingApprovals.find( const pendingApproval = state.pendingApprovals.find(pa => pa.departmentCode === departmentCode);
(pa) => pa.departmentCode === departmentCode,
);
if (!pendingApproval) { if (!pendingApproval) {
throw new BadRequestException( throw new BadRequestException(`No pending approval found for department ${departmentCode}`);
`No pending approval found for department ${departmentCode}`,
);
} }
pendingApproval.status = approvalStatus; pendingApproval.status = approvalStatus;
@@ -155,16 +153,12 @@ export class WorkflowExecutorService {
if (stageComplete.isComplete) { if (stageComplete.isComplete) {
const workflow = await this.workflowRepository.query().findById(state.workflowId); const workflow = await this.workflowRepository.query().findById(state.workflowId);
const definition = (workflow.definition as any) as WorkflowDefinition; const definition = workflow.definition as any as WorkflowDefinition;
const currentStage = definition.stages.find(s => s.stageId === state.currentStageId); const currentStage = definition.stages.find(s => s.stageId === state.currentStageId);
if (stageComplete.rejectionCount > 0) { if (stageComplete.rejectionCount > 0) {
// Handle rejection based on stage configuration // Handle rejection based on stage configuration
const rejectionAction = await this.handleRejection( const rejectionAction = await this.handleRejection(state, currentStage, stageComplete);
state,
currentStage,
stageComplete,
);
actions.push(...rejectionAction.actions); actions.push(...rejectionAction.actions);
failureReason = rejectionAction.failureReason; failureReason = rejectionAction.failureReason;
@@ -205,14 +199,12 @@ export class WorkflowExecutorService {
actions, actions,
actionsMetadata: { actionsMetadata: {
currentStageId: state.currentStageId, currentStageId: state.currentStageId,
nextDepartments: stageAdvanced nextDepartments: stageAdvanced ? state.pendingApprovals.map(pa => pa.departmentCode) : [],
? state.pendingApprovals.map((pa) => pa.departmentCode)
: [],
}, },
stageAdvanced, stageAdvanced,
workflowCompleted, workflowCompleted,
failureReason, failureReason,
nextDepartments: state.pendingApprovals.map((pa) => pa.departmentCode), nextDepartments: state.pendingApprovals.map(pa => pa.departmentCode),
message: `Approval processed for department ${departmentCode}`, message: `Approval processed for department ${departmentCode}`,
}; };
} }
@@ -222,22 +214,20 @@ export class WorkflowExecutorService {
*/ */
async isStageComplete(state: WorkflowState): Promise<StageCompletionCheck> { async isStageComplete(state: WorkflowState): Promise<StageCompletionCheck> {
const workflow = await this.workflowRepository.query().findById(state.workflowId); const workflow = await this.workflowRepository.query().findById(state.workflowId);
const definition = (workflow.definition as any) as WorkflowDefinition; const definition = workflow.definition as any as WorkflowDefinition;
const currentStage = definition.stages.find(s => s.stageId === state.currentStageId); const currentStage = definition.stages.find(s => s.stageId === state.currentStageId);
const pendingApprovals = state.pendingApprovals; const pendingApprovals = state.pendingApprovals;
const approvedCount = pendingApprovals.filter( const approvedCount = pendingApprovals.filter(
(pa) => pa.status === ApprovalStatus.APPROVED, pa => pa.status === ApprovalStatus.APPROVED,
).length; ).length;
const rejectionCount = pendingApprovals.filter( const rejectionCount = pendingApprovals.filter(
(pa) => pa.status === ApprovalStatus.REJECTED, pa => pa.status === ApprovalStatus.REJECTED,
).length; ).length;
const pendingCount = pendingApprovals.filter( const pendingCount = pendingApprovals.filter(pa => pa.status === ApprovalStatus.PENDING).length;
(pa) => pa.status === ApprovalStatus.PENDING,
).length;
const totalRequired = pendingApprovals.length; const totalRequired = pendingApprovals.length;
@@ -257,8 +247,7 @@ export class WorkflowExecutorService {
case CompletionCriteria.THRESHOLD: case CompletionCriteria.THRESHOLD:
// Minimum threshold met // Minimum threshold met
const threshold = currentStage.threshold || 1; const threshold = currentStage.threshold || 1;
isComplete = isComplete = approvedCount >= threshold || approvedCount + pendingCount < threshold;
approvedCount >= threshold || (approvedCount + pendingCount < threshold);
break; break;
} }
@@ -279,7 +268,7 @@ export class WorkflowExecutorService {
nextStage: WorkflowStage, nextStage: WorkflowStage,
): Promise<WorkflowState> { ): Promise<WorkflowState> {
const workflow = await this.workflowRepository.query().findById(state.workflowId); const workflow = await this.workflowRepository.query().findById(state.workflowId);
const definition = (workflow.definition as any) as WorkflowDefinition; const definition = workflow.definition as any as WorkflowDefinition;
const currentStage = definition.stages.find(s => s.stageId === state.currentStageId); const currentStage = definition.stages.find(s => s.stageId === state.currentStageId);
// Mark current stage as complete // Mark current stage as complete
@@ -298,7 +287,7 @@ export class WorkflowExecutorService {
state.currentStageOrder = nextStage.stageOrder; state.currentStageOrder = nextStage.stageOrder;
// Initialize pending approvals for new stage // Initialize pending approvals for new stage
state.pendingApprovals = nextStage.requiredApprovals.map((approval) => ({ state.pendingApprovals = nextStage.requiredApprovals.map(approval => ({
departmentCode: approval.departmentCode, departmentCode: approval.departmentCode,
departmentName: approval.departmentName, departmentName: approval.departmentName,
approvalId: '', approvalId: '',
@@ -311,10 +300,7 @@ export class WorkflowExecutorService {
/** /**
* Handle document update - invalidate approvals * Handle document update - invalidate approvals
*/ */
async handleDocumentUpdate( async handleDocumentUpdate(requestId: string, documentId: string): Promise<DocumentUpdateResult> {
requestId: string,
documentId: string,
): Promise<DocumentUpdateResult> {
const state = await this.getWorkflowState(requestId); const state = await this.getWorkflowState(requestId);
if (!state || state.isWorkflowComplete) { if (!state || state.isWorkflowComplete) {
@@ -326,18 +312,15 @@ export class WorkflowExecutorService {
}; };
} }
const affectedDepartments = const affectedDepartments = await this.approvalsService.invalidateApprovalsByDocument(
await this.approvalsService.invalidateApprovalsByDocument( requestId,
requestId, documentId,
documentId, 'Document was updated',
'Document was updated', );
);
// Reset pending approvals for affected departments // Reset pending approvals for affected departments
for (const deptCode of affectedDepartments) { for (const deptCode of affectedDepartments) {
const pendingApproval = state.pendingApprovals.find( const pendingApproval = state.pendingApprovals.find(pa => pa.departmentCode === deptCode);
(pa) => pa.departmentCode === deptCode,
);
if (pendingApproval) { if (pendingApproval) {
pendingApproval.status = ApprovalStatus.PENDING; pendingApproval.status = ApprovalStatus.PENDING;
@@ -358,10 +341,7 @@ export class WorkflowExecutorService {
/** /**
* Check if department can approve at current stage * Check if department can approve at current stage
*/ */
async canDepartmentApprove( async canDepartmentApprove(requestId: string, departmentCode: string): Promise<boolean> {
requestId: string,
departmentCode: string,
): Promise<boolean> {
const state = await this.getWorkflowState(requestId); const state = await this.getWorkflowState(requestId);
if (!state || state.isWorkflowComplete) { if (!state || state.isWorkflowComplete) {
@@ -369,20 +349,18 @@ export class WorkflowExecutorService {
} }
const workflow = await this.workflowRepository.query().findById(state.workflowId); const workflow = await this.workflowRepository.query().findById(state.workflowId);
const definition = (workflow.definition as any) as WorkflowDefinition; const definition = workflow.definition as any as WorkflowDefinition;
const currentStage = definition.stages.find(s => s.stageId === state.currentStageId); const currentStage = definition.stages.find(s => s.stageId === state.currentStageId);
const isInCurrentStage = currentStage.requiredApprovals.some( const isInCurrentStage = currentStage.requiredApprovals.some(
(ra) => ra.departmentCode === departmentCode, ra => ra.departmentCode === departmentCode,
); );
if (!isInCurrentStage) { if (!isInCurrentStage) {
return false; return false;
} }
const pendingApproval = state.pendingApprovals.find( const pendingApproval = state.pendingApprovals.find(pa => pa.departmentCode === departmentCode);
(pa) => pa.departmentCode === departmentCode,
);
return pendingApproval?.status === ApprovalStatus.PENDING; return pendingApproval?.status === ApprovalStatus.PENDING;
} }
@@ -398,8 +376,8 @@ export class WorkflowExecutorService {
} }
return state.pendingApprovals return state.pendingApprovals
.filter((pa) => pa.status === ApprovalStatus.PENDING) .filter(pa => pa.status === ApprovalStatus.PENDING)
.map((pa) => pa.departmentCode); .map(pa => pa.departmentCode);
} }
/** /**
@@ -470,7 +448,7 @@ export class WorkflowExecutorService {
case RejectionHandling.RETRY_STAGE: case RejectionHandling.RETRY_STAGE:
// Reset all approvals in current stage to PENDING // Reset all approvals in current stage to PENDING
state.pendingApprovals = state.pendingApprovals.map((pa) => ({ state.pendingApprovals = state.pendingApprovals.map(pa => ({
...pa, ...pa,
status: ApprovalStatus.PENDING, status: ApprovalStatus.PENDING,
})); }));
@@ -509,16 +487,14 @@ export class WorkflowExecutorService {
stage: WorkflowStage, stage: WorkflowStage,
approvals: ApprovalResponseDto[], approvals: ApprovalResponseDto[],
): boolean { ): boolean {
const approvedCount = approvals.filter( const approvedCount = approvals.filter(a => a.status === ApprovalStatus.APPROVED).length;
(a) => a.status === ApprovalStatus.APPROVED,
).length;
switch (stage.completionCriteria) { switch (stage.completionCriteria) {
case CompletionCriteria.ALL: case CompletionCriteria.ALL:
return approvals.every((a) => a.status === ApprovalStatus.APPROVED); return approvals.every(a => a.status === ApprovalStatus.APPROVED);
case CompletionCriteria.ANY: case CompletionCriteria.ANY:
return approvals.some((a) => a.status === ApprovalStatus.APPROVED); return approvals.some(a => a.status === ApprovalStatus.APPROVED);
case CompletionCriteria.THRESHOLD: case CompletionCriteria.THRESHOLD:
return approvedCount >= (stage.threshold || 1); return approvedCount >= (stage.threshold || 1);

View File

@@ -1,9 +1,4 @@
import { import { Injectable, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
Injectable,
NotFoundException,
BadRequestException,
Inject,
} from '@nestjs/common';
import { Workflow } from '../../../database/models/workflow.model'; import { Workflow } from '../../../database/models/workflow.model';
import { CreateWorkflowDto } from '../dto/create-workflow.dto'; import { CreateWorkflowDto } from '../dto/create-workflow.dto';
@@ -40,9 +35,7 @@ export class WorkflowsService {
}); });
if (!validation.isValid) { if (!validation.isValid) {
throw new BadRequestException( throw new BadRequestException(`Workflow validation failed: ${validation.errors.join(', ')}`);
`Workflow validation failed: ${validation.errors.join(', ')}`,
);
} }
const definition: WorkflowDefinition = { const definition: WorkflowDefinition = {
@@ -74,7 +67,7 @@ export class WorkflowsService {
const query = this.workflowRepository.query(); const query = this.workflowRepository.query();
if (isActive !== undefined) { if (isActive !== undefined) {
query.where({ isActive }); query.where({ is_active: isActive });
} }
return query.orderBy('created_at', 'DESC'); return query.orderBy('created_at', 'DESC');
@@ -97,14 +90,13 @@ export class WorkflowsService {
* Get workflow by type * Get workflow by type
*/ */
async findByType(workflowType: string): Promise<Workflow> { async findByType(workflowType: string): Promise<Workflow> {
const workflow = await this.workflowRepository.query() const workflow = await this.workflowRepository
.query()
.findOne({ workflowType, isActive: true }) .findOne({ workflowType, isActive: true })
.orderBy('version', 'DESC'); .orderBy('version', 'DESC');
if (!workflow) { if (!workflow) {
throw new NotFoundException( throw new NotFoundException(`No active workflow found for type ${workflowType}`);
`No active workflow found for type ${workflowType}`,
);
} }
return workflow; return workflow;
@@ -205,9 +197,7 @@ export class WorkflowsService {
} }
if (stage.stageOrder !== index) { if (stage.stageOrder !== index) {
warnings.push( warnings.push(`Stage ${index} has stageOrder ${stage.stageOrder}, expected ${index}`);
`Stage ${index} has stageOrder ${stage.stageOrder}, expected ${index}`,
);
} }
if (!stage.requiredApprovals || stage.requiredApprovals.length === 0) { if (!stage.requiredApprovals || stage.requiredApprovals.length === 0) {
@@ -217,26 +207,16 @@ export class WorkflowsService {
// Validate completion criteria // Validate completion criteria
if (stage.completionCriteria === CompletionCriteria.THRESHOLD) { if (stage.completionCriteria === CompletionCriteria.THRESHOLD) {
if (!stage.threshold || stage.threshold < 1) { if (!stage.threshold || stage.threshold < 1) {
errors.push( errors.push(`Stage ${stage.stageId} uses THRESHOLD but no valid threshold is set`);
`Stage ${stage.stageId} uses THRESHOLD but no valid threshold is set`,
);
} }
if ( if (stage.threshold && stage.threshold > stage.requiredApprovals.length) {
stage.threshold && errors.push(`Stage ${stage.stageId} threshold exceeds number of required approvals`);
stage.threshold > stage.requiredApprovals.length
) {
errors.push(
`Stage ${stage.stageId} threshold exceeds number of required approvals`,
);
} }
} }
// Validate rejection handling // Validate rejection handling
if ( if (stage.rejectionHandling === 'ESCALATE' && !stage.escalationDepartment) {
stage.rejectionHandling === 'ESCALATE' &&
!stage.escalationDepartment
) {
warnings.push( warnings.push(
`Stage ${stage.stageId} uses ESCALATE but no escalationDepartment is configured`, `Stage ${stage.stageId} uses ESCALATE but no escalationDepartment is configured`,
); );
@@ -262,11 +242,11 @@ export class WorkflowsService {
workflowId: workflow.id, workflowId: workflow.id,
workflowType: workflow.workflowType, workflowType: workflow.workflowType,
name: workflow.name, name: workflow.name,
stages: definition.stages.map((stage) => ({ stages: definition.stages.map(stage => ({
stageId: stage.stageId, stageId: stage.stageId,
stageName: stage.stageName, stageName: stage.stageName,
stageOrder: stage.stageOrder, stageOrder: stage.stageOrder,
departments: stage.requiredApprovals.map((ra) => ra.departmentCode), departments: stage.requiredApprovals.map(ra => ra.departmentCode),
executionType: stage.executionType, executionType: stage.executionType,
completionCriteria: stage.completionCriteria, completionCriteria: stage.completionCriteria,
})), })),

View File

@@ -66,7 +66,8 @@ export class WorkflowsController {
@ApiResponse({ status: 200, description: 'List of workflows' }) @ApiResponse({ status: 200, description: 'List of workflows' })
async findAll(@Query('isActive') isActive?: string) { async findAll(@Query('isActive') isActive?: string) {
const active = isActive !== undefined ? isActive === 'true' : undefined; const active = isActive !== undefined ? isActive === 'true' : undefined;
return this.workflowsService.findAll(active); const workflows = await this.workflowsService.findAll(active);
return { data: workflows, total: workflows.length };
} }
@Get(':id') @Get(':id')

View File

@@ -5,9 +5,7 @@ import { WorkflowExecutorService } from './services/workflow-executor.service';
import { ApprovalsModule } from '../approvals/approvals.module'; import { ApprovalsModule } from '../approvals/approvals.module';
@Module({ @Module({
imports: [ imports: [ApprovalsModule],
ApprovalsModule,
],
controllers: [WorkflowsController], controllers: [WorkflowsController],
providers: [WorkflowsService, WorkflowExecutorService], providers: [WorkflowsService, WorkflowExecutorService],
exports: [WorkflowsService, WorkflowExecutorService], exports: [WorkflowsService, WorkflowExecutorService],

View File

@@ -1,112 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>blockchain-architecture</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a1a;
color: #fff;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #3b82f6;
}
.mermaid {
display: flex;
justify-content: center;
background: transparent;
}
</style>
</head>
<body>
<h1>BLOCKCHAIN ARCHITECTURE</h1>
<div class="mermaid">
graph TB
subgraph Network["Hyperledger Besu Network<br/>QBFT Consensus<br/>4 Validator Nodes"]
V1["🔐 Validator Node 1<br/>Port: 8545<br/>RPC Endpoint"]
V2["🔐 Validator Node 2<br/>Port: 8546"]
V3["🔐 Validator Node 3<br/>Port: 8547"]
V4["🔐 Validator Node 4<br/>Port: 8548"]
end
subgraph SmartContracts["Smart Contracts"]
LicenseNFT["📋 LicenseRequestNFT<br/>(ERC-721 Soulbound)<br/>• tokenId<br/>• licenseHash<br/>• metadata URI<br/>• issuerDept"]
ApprovalMgr["✅ ApprovalManager<br/>• recordApproval()<br/>• rejectRequest()<br/>• requestChanges()<br/>• getApprovalChain()"]
DeptRegistry["🏢 DepartmentRegistry<br/>• registerDept()<br/>• setApprovers()<br/>• getApprovers()<br/>• deptMetadata"]
WorkflowRegistry["⚙️ WorkflowRegistry<br/>• defineWorkflow()<br/>• getWorkflow()<br/>• workflowStates<br/>• transitions"]
end
subgraph OnChain["On-Chain Data"]
Accounts["💰 Accounts & Balances"]
NFTState["🎖️ NFT State<br/>tokenId → Owner<br/>tokenId → Metadata"]
Approvals["✅ Approval Records<br/>licenseHash → ApprovalChain"]
end
subgraph OffChain["Off-Chain Data<br/>PostgreSQL + MinIO"]
DocMeta["📄 Document Metadata<br/>• documentId<br/>• licenseHash<br/>• uploadedBy<br/>• uploadDate<br/>• status"]
LicenseReq["📋 License Request Details<br/>• requestId<br/>• applicantInfo<br/>• documents<br/>• notes"]
WorkflowState["⚙️ Workflow State<br/>• currentState<br/>• stateHistory<br/>• timestamps<br/>• transitions"]
DocFiles["📦 Actual Files<br/>• PDFs (MinIO)<br/>• Images<br/>• Proofs"]
end
subgraph DataLink["Data Linking"]
Hash["🔗 Content Hashing<br/>SHA-256<br/>Document → Hash<br/>Immutable Link"]
end
subgraph Consensus["Consensus: QBFT"]
QBFTInfo["Quorum Byzantine<br/>Fault Tolerant<br/>Requires 3/4 validators<br/>~1 block/12s"]
end
V1 -->|Peer Connection| V2
V1 -->|Peer Connection| V3
V1 -->|Peer Connection| V4
V2 -->|Peer Connection| V3
V2 -->|Peer Connection| V4
V3 -->|Peer Connection| V4
V1 -->|Deploy/Call| SmartContracts
V2 -->|Deploy/Call| SmartContracts
V3 -->|Deploy/Call| SmartContracts
V4 -->|Deploy/Call| SmartContracts
SmartContracts -->|Store State| OnChain
LicenseNFT -->|Emit Events| OnChain
ApprovalMgr -->|Record| OnChain
DeptRegistry -->|Maintain| OnChain
WorkflowRegistry -->|Track| OnChain
Hash -->|Link Via Hash| LicenseReq
Hash -->|Store Hash| OnChain
DocMeta -->|Contains Hash| Hash
LicenseReq -->|Store Details| OffChain
WorkflowState -->|Track Off-Chain| OffChain
DocFiles -->|Reference Via Hash| OffChain
Hash -.->|Immutable Anchor| NFTState
LicenseReq -.->|Linked to NFT| LicenseNFT
V1 -->|Consensus| Consensus
V2 -->|Consensus| Consensus
V3 -->|Consensus| Consensus
V4 -->|Consensus| Consensus
style Network fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff
style SmartContracts fill:#2563eb,stroke:#1e40af,stroke-width:2px,color:#fff
style OnChain fill:#7c3aed,stroke:#5b21b6,stroke-width:2px,color:#fff
style OffChain fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff
style DataLink fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff
style Consensus fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
mermaid.contentLoaded();
</script>
</body>
</html>

View File

@@ -1,75 +0,0 @@
graph TB
subgraph Network["Hyperledger Besu Network<br/>QBFT Consensus<br/>4 Validator Nodes"]
V1["🔐 Validator Node 1<br/>Port: 8545<br/>RPC Endpoint"]
V2["🔐 Validator Node 2<br/>Port: 8546"]
V3["🔐 Validator Node 3<br/>Port: 8547"]
V4["🔐 Validator Node 4<br/>Port: 8548"]
end
subgraph SmartContracts["Smart Contracts"]
LicenseNFT["📋 LicenseRequestNFT<br/>(ERC-721 Soulbound)<br/>• tokenId<br/>• licenseHash<br/>• metadata URI<br/>• issuerDept"]
ApprovalMgr["✅ ApprovalManager<br/>• recordApproval()<br/>• rejectRequest()<br/>• requestChanges()<br/>• getApprovalChain()"]
DeptRegistry["🏢 DepartmentRegistry<br/>• registerDept()<br/>• setApprovers()<br/>• getApprovers()<br/>• deptMetadata"]
WorkflowRegistry["⚙️ WorkflowRegistry<br/>• defineWorkflow()<br/>• getWorkflow()<br/>• workflowStates<br/>• transitions"]
end
subgraph OnChain["On-Chain Data"]
Accounts["💰 Accounts & Balances"]
NFTState["🎖️ NFT State<br/>tokenId → Owner<br/>tokenId → Metadata"]
Approvals["✅ Approval Records<br/>licenseHash → ApprovalChain"]
end
subgraph OffChain["Off-Chain Data<br/>PostgreSQL + MinIO"]
DocMeta["📄 Document Metadata<br/>• documentId<br/>• licenseHash<br/>• uploadedBy<br/>• uploadDate<br/>• status"]
LicenseReq["📋 License Request Details<br/>• requestId<br/>• applicantInfo<br/>• documents<br/>• notes"]
WorkflowState["⚙️ Workflow State<br/>• currentState<br/>• stateHistory<br/>• timestamps<br/>• transitions"]
DocFiles["📦 Actual Files<br/>• PDFs (MinIO)<br/>• Images<br/>• Proofs"]
end
subgraph DataLink["Data Linking"]
Hash["🔗 Content Hashing<br/>SHA-256<br/>Document → Hash<br/>Immutable Link"]
end
subgraph Consensus["Consensus: QBFT"]
QBFTInfo["Quorum Byzantine<br/>Fault Tolerant<br/>Requires 3/4 validators<br/>~1 block/12s"]
end
V1 -->|Peer Connection| V2
V1 -->|Peer Connection| V3
V1 -->|Peer Connection| V4
V2 -->|Peer Connection| V3
V2 -->|Peer Connection| V4
V3 -->|Peer Connection| V4
V1 -->|Deploy/Call| SmartContracts
V2 -->|Deploy/Call| SmartContracts
V3 -->|Deploy/Call| SmartContracts
V4 -->|Deploy/Call| SmartContracts
SmartContracts -->|Store State| OnChain
LicenseNFT -->|Emit Events| OnChain
ApprovalMgr -->|Record| OnChain
DeptRegistry -->|Maintain| OnChain
WorkflowRegistry -->|Track| OnChain
Hash -->|Link Via Hash| LicenseReq
Hash -->|Store Hash| OnChain
DocMeta -->|Contains Hash| Hash
LicenseReq -->|Store Details| OffChain
WorkflowState -->|Track Off-Chain| OffChain
DocFiles -->|Reference Via Hash| OffChain
Hash -.->|Immutable Anchor| NFTState
LicenseReq -.->|Linked to NFT| LicenseNFT
V1 -->|Consensus| Consensus
V2 -->|Consensus| Consensus
V3 -->|Consensus| Consensus
V4 -->|Consensus| Consensus
style Network fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff
style SmartContracts fill:#2563eb,stroke:#1e40af,stroke-width:2px,color:#fff
style OnChain fill:#7c3aed,stroke:#5b21b6,stroke-width:2px,color:#fff
style OffChain fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff
style DataLink fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff
style Consensus fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff

View File

@@ -1,101 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>container-architecture</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a1a;
color: #fff;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #3b82f6;
}
.mermaid {
display: flex;
justify-content: center;
background: transparent;
}
</style>
</head>
<body>
<h1>CONTAINER ARCHITECTURE</h1>
<div class="mermaid">
graph TB
subgraph Client["Client Layer"]
WEB["🌐 Next.js 14 Frontend<br/>shadcn/ui<br/>Port: 3000"]
end
subgraph API["API & Backend Layer"]
APIGW["📡 API Gateway<br/>NestJS<br/>Port: 3001"]
AUTH["🔐 Auth Service<br/>API Key + Secret<br/>(POC)"]
WORKFLOW["⚙️ Workflow Service<br/>NestJS Module"]
APPROVAL["✅ Approval Service<br/>NestJS Module"]
DOCUMENT["📄 Document Service<br/>NestJS Module"]
end
subgraph Data["Data Layer"]
DB["🗄️ PostgreSQL<br/>Port: 5432<br/>license_requests<br/>approvals, documents<br/>audit_logs"]
CACHE["⚡ Redis Cache<br/>Port: 6379<br/>Session, Workflow State"]
STORAGE["📦 MinIO<br/>Port: 9000<br/>Document Files<br/>License PDFs"]
end
subgraph Blockchain["Blockchain Layer"]
BESU["⛓️ Hyperledger Besu<br/>QBFT Consensus<br/>Port: 8545"]
CONTRACTS["📋 Smart Contracts<br/>• LicenseRequestNFT<br/>• ApprovalManager<br/>• DepartmentRegistry<br/>• WorkflowRegistry"]
BCDB["📚 Chain State<br/>Account Balances<br/>NFT Metadata"]
end
subgraph Integrations["External Integrations"]
DIGILOCKER["📱 DigiLocker Mock<br/>Document Verification"]
LEGACY["💼 Legacy Systems<br/>Data Integration"]
WEBHOOK["🔔 Webhook Service<br/>Event Notifications"]
end
WEB -->|REST/GraphQL| APIGW
APIGW -->|Validate Token| AUTH
APIGW -->|Route Request| WORKFLOW
APIGW -->|Route Request| APPROVAL
APIGW -->|Route Request| DOCUMENT
WORKFLOW -->|Read/Write| DB
WORKFLOW -->|Cache State| CACHE
WORKFLOW -->|Submit TX| BESU
APPROVAL -->|Read/Write| DB
APPROVAL -->|Cache| CACHE
APPROVAL -->|Smart Contract Call| BESU
DOCUMENT -->|Store Files| STORAGE
DOCUMENT -->|Hash Generation| DOCUMENT
DOCUMENT -->|Record Hash| BESU
BESU -->|Execute| CONTRACTS
CONTRACTS -->|Update State| BCDB
APIGW -->|Verify Docs| DIGILOCKER
APIGW -->|Query Legacy| LEGACY
APPROVAL -->|Send Event| WEBHOOK
style WEB fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff
style APIGW fill:#3b82f6,stroke:#1e40af,stroke-width:2px,color:#fff
style AUTH fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff
style DB fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff
style STORAGE fill:#ec4899,stroke:#be123c,stroke-width:2px,color:#fff
style CACHE fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff
style BESU fill:#ef4444,stroke:#991b1b,stroke-width:2px,color:#fff
style CONTRACTS fill:#dc2626,stroke:#7f1d1d,stroke-width:2px,color:#fff
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
mermaid.contentLoaded();
</script>
</body>
</html>

View File

@@ -1,64 +0,0 @@
graph TB
subgraph Client["Client Layer"]
WEB["🌐 Next.js 14 Frontend<br/>shadcn/ui<br/>Port: 3000"]
end
subgraph API["API & Backend Layer"]
APIGW["📡 API Gateway<br/>NestJS<br/>Port: 3001"]
AUTH["🔐 Auth Service<br/>API Key + Secret<br/>(POC)"]
WORKFLOW["⚙️ Workflow Service<br/>NestJS Module"]
APPROVAL["✅ Approval Service<br/>NestJS Module"]
DOCUMENT["📄 Document Service<br/>NestJS Module"]
end
subgraph Data["Data Layer"]
DB["🗄️ PostgreSQL<br/>Port: 5432<br/>license_requests<br/>approvals, documents<br/>audit_logs"]
CACHE["⚡ Redis Cache<br/>Port: 6379<br/>Session, Workflow State"]
STORAGE["📦 MinIO<br/>Port: 9000<br/>Document Files<br/>License PDFs"]
end
subgraph Blockchain["Blockchain Layer"]
BESU["⛓️ Hyperledger Besu<br/>QBFT Consensus<br/>Port: 8545"]
CONTRACTS["📋 Smart Contracts<br/>• LicenseRequestNFT<br/>• ApprovalManager<br/>• DepartmentRegistry<br/>• WorkflowRegistry"]
BCDB["📚 Chain State<br/>Account Balances<br/>NFT Metadata"]
end
subgraph Integrations["External Integrations"]
DIGILOCKER["📱 DigiLocker Mock<br/>Document Verification"]
LEGACY["💼 Legacy Systems<br/>Data Integration"]
WEBHOOK["🔔 Webhook Service<br/>Event Notifications"]
end
WEB -->|REST/GraphQL| APIGW
APIGW -->|Validate Token| AUTH
APIGW -->|Route Request| WORKFLOW
APIGW -->|Route Request| APPROVAL
APIGW -->|Route Request| DOCUMENT
WORKFLOW -->|Read/Write| DB
WORKFLOW -->|Cache State| CACHE
WORKFLOW -->|Submit TX| BESU
APPROVAL -->|Read/Write| DB
APPROVAL -->|Cache| CACHE
APPROVAL -->|Smart Contract Call| BESU
DOCUMENT -->|Store Files| STORAGE
DOCUMENT -->|Hash Generation| DOCUMENT
DOCUMENT -->|Record Hash| BESU
BESU -->|Execute| CONTRACTS
CONTRACTS -->|Update State| BCDB
APIGW -->|Verify Docs| DIGILOCKER
APIGW -->|Query Legacy| LEGACY
APPROVAL -->|Send Event| WEBHOOK
style WEB fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff
style APIGW fill:#3b82f6,stroke:#1e40af,stroke-width:2px,color:#fff
style AUTH fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff
style DB fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff
style STORAGE fill:#ec4899,stroke:#be123c,stroke-width:2px,color:#fff
style CACHE fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff
style BESU fill:#ef4444,stroke:#991b1b,stroke-width:2px,color:#fff
style CONTRACTS fill:#dc2626,stroke:#7f1d1d,stroke-width:2px,color:#fff

View File

@@ -1,235 +0,0 @@
const fs = require('fs');
const path = require('path');
// Create simple PNG placeholders with SVG conversion instructions
// Since mermaid-cli is not available, we'll create a comprehensive README
const diagrams = [
{ file: 'system-context.mermaid', title: 'System Context Diagram' },
{ file: 'container-architecture.mermaid', title: 'Container Architecture' },
{ file: 'blockchain-architecture.mermaid', title: 'Blockchain Architecture' },
{ file: 'workflow-state-machine.mermaid', title: 'Workflow State Machine' },
{ file: 'data-flow.mermaid', title: 'Data Flow Diagram' },
{ file: 'deployment-architecture.mermaid', title: 'Deployment Architecture' }
];
let readme = `# Goa GEL Blockchain Document Verification Platform - Architecture Diagrams
## Overview
This directory contains comprehensive architecture diagrams for the Goa Government E-License (GEL) Blockchain Document Verification Platform.
## Diagrams
`;
diagrams.forEach(({ file, title }) => {
readme += `### ${title}
- **File:** \`${file}\`
- **Type:** Mermaid Diagram
`;
});
readme += `
## Converting Mermaid to PNG
### Option 1: Online Converter
Visit https://mermaid.live and:
1. Click "Upload File"
2. Select each .mermaid file
3. Click the download icon to export as PNG
### Option 2: Using Mermaid CLI (Local Installation)
\`\`\`bash
# Install locally
npm install --save-dev @mermaid-js/mermaid-cli
# Convert all files
npx mmdc -i system-context.mermaid -o system-context.png -t dark -b transparent
npx mmdc -i container-architecture.mermaid -o container-architecture.png -t dark -b transparent
npx mmdc -i blockchain-architecture.mermaid -o blockchain-architecture.png -t dark -b transparent
npx mmdc -i workflow-state-machine.mermaid -o workflow-state-machine.png -t dark -b transparent
npx mmdc -i data-flow.mermaid -o data-flow.png -t dark -b transparent
npx mmdc -i deployment-architecture.mermaid -o deployment-architecture.png -t dark -b transparent
\`\`\`
### Option 3: Using Docker
\`\`\`bash
docker run --rm -v $(pwd):/data mermaid/mermaid-cli:latest \\
-i /data/system-context.mermaid \\
-o /data/system-context.png \\
-t dark -b transparent
\`\`\`
### Option 4: Browser Method
Open each .html file in a web browser and:
1. Press F12 to open DevTools
2. Use Chrome DevTools to capture the diagram as an image
3. Or use a screenshot tool
## Diagram Contents
### 1. system-context.mermaid
**C4 Level 1 Context Diagram**
- Shows the GEL platform as a black box
- External actors: Citizens, Government Departments, Department Operators, Platform Operators
- External systems: DigiLocker Mock, Legacy Department Systems, National Blockchain Federation (future)
### 2. container-architecture.mermaid
**C4 Level 2 Container Diagram**
- Frontend: Next.js 14 with shadcn/ui (Port 3000)
- Backend: NestJS API Gateway (Port 3001)
- Database: PostgreSQL (Port 5432)
- Cache: Redis (Port 6379)
- Storage: MinIO S3-compatible (Port 9000)
- Blockchain: Hyperledger Besu nodes
- Services: Auth, Workflow, Approval, Document
### 3. blockchain-architecture.mermaid
**Blockchain Layer Deep Dive**
- 4 Hyperledger Besu Validator Nodes (QBFT Consensus)
- RPC Ports: 8545-8548
- Smart Contracts:
- LicenseRequestNFT (ERC-721 Soulbound)
- ApprovalManager
- DepartmentRegistry
- WorkflowRegistry
- On-Chain vs Off-Chain Data Split
- Content Hashing (SHA-256) for Immutable Links
### 4. workflow-state-machine.mermaid
**License Request Workflow States**
States:
- DRAFT: Initial local draft
- SUBMITTED: Hash recorded on blockchain
- IN_REVIEW: Multi-department approval
- PENDING_RESUBMISSION: Changes requested
- APPROVED: License granted, NFT minted
- REJECTED: Request denied
- REVOKED: License cancelled
### 5. data-flow.mermaid
**Complete End-to-End Sequence**
11-Step Process:
1. License Request Submission
2. Document Upload & Hashing
3. Blockchain Recording
4. State Update to SUBMITTED
5. Route to Department 1 (Tourism)
6. Route to Department 2 (Fire Safety) - Parallel
7. Department 1 Approval
8. Department 2 Approval - Parallel
9. Final Approval Processing
10. Update Final State & Notifications
11. License Verification
### 6. deployment-architecture.mermaid
**Docker Compose Deployment**
Services:
- Frontend: Next.js (Port 3000)
- Backend: NestJS (Port 3001)
- Database: PostgreSQL (Port 5432)
- Cache: Redis (Port 6379)
- Storage: MinIO (Port 9000, 9001)
- Blockchain: 4x Besu Validators (Ports 8545-8548)
- Monitoring: Prometheus (9090), Grafana (3000 alt)
Volumes & Configuration Files
## Key Technical Decisions
### Blockchain
- **Platform:** Hyperledger Besu
- **Consensus:** QBFT (Quorum Byzantine Fault Tolerant)
- **Network Type:** Private Permissioned
- **Validators:** 4 nodes (requires 3/4 approval)
- **Block Time:** ~12 seconds
### Tokens
- **Standard:** ERC-721
- **Type:** Soulbound NFTs
- **Purpose:** Non-transferable license certificates
- **Metadata:** Immutable license details
### Backend
- **Framework:** NestJS (TypeScript)
- **Database:** PostgreSQL
- **File Storage:** MinIO (S3-compatible)
- **Cache:** Redis
### Frontend
- **Framework:** Next.js 14
- **UI:** shadcn/ui
- **State Management:** React Context/TanStack Query
- **Styling:** Tailwind CSS
### Authentication
- **POC Phase:** API Key + Secret
- **Future:** DigiLocker Integration (Mocked)
## Architecture Benefits
1. **Immutable Records**: Blockchain ensures license records cannot be tampered with
2. **Multi-Department Workflows**: Parallel or sequential approvals based on license type
3. **Transparent Verification**: Anyone can verify license authenticity on blockchain
4. **Scalability**: Off-chain document storage with on-chain hashing
5. **Auditability**: Complete audit trail of all state changes
6. **Privacy**: Permissioned network with department access controls
7. **Future-Proof**: NFT standard enables future interoperability
## Viewing Instructions
1. **Mermaid Live** (Easiest): https://mermaid.live
- Copy-paste content from .mermaid files
- Instant preview and export
2. **HTML Files** (Built-in Browser):
- Open system-context.html (and others) in any web browser
- Uses CDN-hosted mermaid.js for rendering
3. **PNG Export**:
- Follow the conversion options above
- Recommended: Use mermaid-cli or online converter
## File Listing
\`\`\`
/sessions/cool-elegant-faraday/mnt/Goa-GEL/
├── system-context.mermaid
├── system-context.html
├── container-architecture.mermaid
├── container-architecture.html
├── blockchain-architecture.mermaid
├── blockchain-architecture.html
├── workflow-state-machine.mermaid
├── workflow-state-machine.html
├── data-flow.mermaid
├── data-flow.html
├── deployment-architecture.mermaid
├── deployment-architecture.html
├── convert.js
├── convert-to-png.js
└── README.md
\`\`\`
## Next Steps
1. Review all diagrams to understand system architecture
2. Use these for documentation and stakeholder presentations
3. Convert to PNG/SVG for inclusion in technical documentation
4. Share with team for feedback and refinement
---
**Generated:** 2026-02-03
**Platform:** Goa GEL Blockchain Document Verification
**Version:** POC 1.0
`;
fs.writeFileSync(path.join(__dirname, 'README.md'), readme);
console.log('README.md created successfully!');
console.log('\nDiagrams created:');
diagrams.forEach(d => {
console.log(` - ${d.file}`);
console.log(` └─ ${d.file.replace('.mermaid', '.html')} (viewable in browser)`);
});

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
// Simple solution: Create a convert-to-html script that uses mermaid.js
// Since we can't install globally, we'll create an HTML file for each diagram
const diagrams = [
'system-context.mermaid',
'container-architecture.mermaid',
'blockchain-architecture.mermaid',
'workflow-state-machine.mermaid',
'data-flow.mermaid',
'deployment-architecture.mermaid'
];
const dir = __dirname;
diagrams.forEach(diagram => {
const mermaidPath = path.join(dir, diagram);
const htmlPath = path.join(dir, diagram.replace('.mermaid', '.html'));
if (fs.existsSync(mermaidPath)) {
const content = fs.readFileSync(mermaidPath, 'utf8');
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${diagram.replace('.mermaid', '')}</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a1a;
color: #fff;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #3b82f6;
}
.mermaid {
display: flex;
justify-content: center;
background: transparent;
}
</style>
</head>
<body>
<h1>${diagram.replace('.mermaid', '').replace(/-/g, ' ').toUpperCase()}</h1>
<div class="mermaid">
${content}
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
mermaid.contentLoaded();
</script>
</body>
</html>`;
fs.writeFileSync(htmlPath, html);
console.log(`Created: ${htmlPath}`);
}
});
console.log('HTML conversion complete!');
console.log('Open each .html file in a browser and use browser tools to export as PNG');

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>data-flow</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a1a;
color: #fff;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #3b82f6;
}
.mermaid {
display: flex;
justify-content: center;
background: transparent;
}
</style>
</head>
<body>
<h1>DATA FLOW</h1>
<div class="mermaid">
sequenceDiagram
participant Citizen as 👤 Citizen
participant Frontend as 🌐 Frontend<br/>Next.js
participant API as 📡 NestJS API
participant DB as 🗄️ PostgreSQL
participant MinIO as 📦 MinIO
participant Blockchain as ⛓️ Besu<br/>Smart Contracts
participant Dept1 as 🏢 Dept 1<br/>Approver
participant Dept2 as 🏢 Dept 2<br/>Approver
participant Webhook as 🔔 Webhook
rect rgb(31, 41, 55)
note over Citizen,API: 1. License Request Submission
Citizen->>Frontend: Create Resort License<br/>Request & Upload<br/>Documents
Frontend->>API: POST /licenses/create<br/>Form Data + Files
API->>DB: Create license_request<br/>status: DRAFT
end
rect rgb(59, 130, 246)
note over API,MinIO: 2. Document Upload & Hashing
API->>MinIO: Upload Documents<br/>(PDF, Images, etc.)
MinIO-->>API: Document URLs
API->>API: Generate SHA-256<br/>Hash of Files
API->>DB: Store document_metadata<br/>with content_hash
end
rect rgb(168, 85, 247)
note over API,Blockchain: 3. Blockchain Recording
API->>Blockchain: Call DocumentRegistrar<br/>recordDocumentHash()<br/>params: licenseHash,<br/>department, timestamp
Blockchain->>Blockchain: Emit DocumentHashRecorded<br/>event
Blockchain->>DB: Store blockchain<br/>tx_hash in license_request
API-->>Frontend: Request Submitted
Frontend-->>Citizen: Confirmation + Request ID
end
rect rgb(20, 184, 166)
note over DB,DB: 4. Update to SUBMITTED State
API->>DB: Update license_request<br/>status: SUBMITTED
API->>DB: Create audit_log entry
end
rect rgb(59, 130, 246)
note over API,Dept1: 5. Route to Department 1
API->>API: Resolve workflow for<br/>Resort License POC
API->>DB: Create approval_request<br/>status: PENDING<br/>department: Tourism
API->>Webhook: Send notification
Webhook->>Dept1: Email: License<br/>Ready for Review
end
rect rgb(139, 92, 246)
note over API,Dept2: 6. Route to Department 2 (Parallel)
par Department 2 Review
API->>DB: Create approval_request<br/>status: PENDING<br/>department: Fire Safety
API->>Webhook: Send notification
Webhook->>Dept2: Email: License<br/>Ready for Review
end
end
rect rgb(34, 197, 94)
note over Dept1,Blockchain: 7. Department 1 Approval
Dept1->>Frontend: Review Documents<br/>& Attachments
Dept1->>API: POST /approvals/approve<br/>approval_id, comments
API->>DB: Update approval_request<br/>status: APPROVED<br/>reviewed_by, timestamp
API->>Blockchain: Call ApprovalManager<br/>recordApproval()<br/>params: licenseHash,<br/>department, signature
Blockchain->>Blockchain: Emit ApprovalRecorded
end
rect rgb(34, 197, 94)
note over Dept2,Blockchain: 8. Department 2 Approval (Parallel)
par Department 2 Review
Dept2->>Frontend: Review Documents
Dept2->>API: POST /approvals/approve
API->>DB: Update approval_request<br/>status: APPROVED
API->>Blockchain: recordApproval()
Blockchain->>Blockchain: Emit ApprovalRecorded
end
end
rect rgb(236, 72, 153)
note over API,Blockchain: 9. Final Approval Processing
API->>API: Check all approvals<br/>complete
API->>Blockchain: Call LicenseRequestNFT<br/>mint()<br/>params: applicant,<br/>licenseURI, metadata
Blockchain->>Blockchain: Mint ERC-721<br/>Soulbound NFT
Blockchain->>Blockchain: Emit Transfer event
end
rect rgb(20, 184, 166)
note over DB,Frontend: 10. Update Final State
API->>DB: Update license_request<br/>status: APPROVED<br/>nft_token_id
API->>DB: Create audit_log<br/>entry: APPROVED
API->>Webhook: Send notification
Webhook->>Citizen: Email: License<br/>Approved!
API-->>Frontend: License Approved
Frontend-->>Citizen: Display NFT &<br/>Certificate
end
rect rgb(96, 125, 139)
note over Citizen,Frontend: 11. License Verification
Citizen->>Frontend: Download License<br/>Certificate
Frontend->>API: GET /licenses/{id}<br/>/verify
API->>Blockchain: query getLicenseNFT()<br/>tokenId
Blockchain-->>API: NFT metadata,<br/>owner, issuer
API-->>Frontend: Verified ✓
Frontend-->>Citizen: Display Verified<br/>License Certificate
end
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
mermaid.contentLoaded();
</script>
</body>
</html>

View File

@@ -1,105 +0,0 @@
sequenceDiagram
participant Citizen as 👤 Citizen
participant Frontend as 🌐 Frontend<br/>Next.js
participant API as 📡 NestJS API
participant DB as 🗄️ PostgreSQL
participant MinIO as 📦 MinIO
participant Blockchain as ⛓️ Besu<br/>Smart Contracts
participant Dept1 as 🏢 Dept 1<br/>Approver
participant Dept2 as 🏢 Dept 2<br/>Approver
participant Webhook as 🔔 Webhook
rect rgb(31, 41, 55)
note over Citizen,API: 1. License Request Submission
Citizen->>Frontend: Create Resort License<br/>Request & Upload<br/>Documents
Frontend->>API: POST /licenses/create<br/>Form Data + Files
API->>DB: Create license_request<br/>status: DRAFT
end
rect rgb(59, 130, 246)
note over API,MinIO: 2. Document Upload & Hashing
API->>MinIO: Upload Documents<br/>(PDF, Images, etc.)
MinIO-->>API: Document URLs
API->>API: Generate SHA-256<br/>Hash of Files
API->>DB: Store document_metadata<br/>with content_hash
end
rect rgb(168, 85, 247)
note over API,Blockchain: 3. Blockchain Recording
API->>Blockchain: Call DocumentRegistrar<br/>recordDocumentHash()<br/>params: licenseHash,<br/>department, timestamp
Blockchain->>Blockchain: Emit DocumentHashRecorded<br/>event
Blockchain->>DB: Store blockchain<br/>tx_hash in license_request
API-->>Frontend: Request Submitted
Frontend-->>Citizen: Confirmation + Request ID
end
rect rgb(20, 184, 166)
note over DB,DB: 4. Update to SUBMITTED State
API->>DB: Update license_request<br/>status: SUBMITTED
API->>DB: Create audit_log entry
end
rect rgb(59, 130, 246)
note over API,Dept1: 5. Route to Department 1
API->>API: Resolve workflow for<br/>Resort License POC
API->>DB: Create approval_request<br/>status: PENDING<br/>department: Tourism
API->>Webhook: Send notification
Webhook->>Dept1: Email: License<br/>Ready for Review
end
rect rgb(139, 92, 246)
note over API,Dept2: 6. Route to Department 2 (Parallel)
par Department 2 Review
API->>DB: Create approval_request<br/>status: PENDING<br/>department: Fire Safety
API->>Webhook: Send notification
Webhook->>Dept2: Email: License<br/>Ready for Review
end
end
rect rgb(34, 197, 94)
note over Dept1,Blockchain: 7. Department 1 Approval
Dept1->>Frontend: Review Documents<br/>& Attachments
Dept1->>API: POST /approvals/approve<br/>approval_id, comments
API->>DB: Update approval_request<br/>status: APPROVED<br/>reviewed_by, timestamp
API->>Blockchain: Call ApprovalManager<br/>recordApproval()<br/>params: licenseHash,<br/>department, signature
Blockchain->>Blockchain: Emit ApprovalRecorded
end
rect rgb(34, 197, 94)
note over Dept2,Blockchain: 8. Department 2 Approval (Parallel)
par Department 2 Review
Dept2->>Frontend: Review Documents
Dept2->>API: POST /approvals/approve
API->>DB: Update approval_request<br/>status: APPROVED
API->>Blockchain: recordApproval()
Blockchain->>Blockchain: Emit ApprovalRecorded
end
end
rect rgb(236, 72, 153)
note over API,Blockchain: 9. Final Approval Processing
API->>API: Check all approvals<br/>complete
API->>Blockchain: Call LicenseRequestNFT<br/>mint()<br/>params: applicant,<br/>licenseURI, metadata
Blockchain->>Blockchain: Mint ERC-721<br/>Soulbound NFT
Blockchain->>Blockchain: Emit Transfer event
end
rect rgb(20, 184, 166)
note over DB,Frontend: 10. Update Final State
API->>DB: Update license_request<br/>status: APPROVED<br/>nft_token_id
API->>DB: Create audit_log<br/>entry: APPROVED
API->>Webhook: Send notification
Webhook->>Citizen: Email: License<br/>Approved!
API-->>Frontend: License Approved
Frontend-->>Citizen: Display NFT &<br/>Certificate
end
rect rgb(96, 125, 139)
note over Citizen,Frontend: 11. License Verification
Citizen->>Frontend: Download License<br/>Certificate
Frontend->>API: GET /licenses/{id}<br/>/verify
API->>Blockchain: query getLicenseNFT()<br/>tokenId
Blockchain-->>API: NFT metadata,<br/>owner, issuer
API-->>Frontend: Verified ✓
Frontend-->>Citizen: Display Verified<br/>License Certificate
end

View File

@@ -1,139 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>deployment-architecture</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a1a;
color: #fff;
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
color: #3b82f6;
}
.mermaid {
display: flex;
justify-content: center;
background: transparent;
}
</style>
</head>
<body>
<h1>DEPLOYMENT ARCHITECTURE</h1>
<div class="mermaid">
graph TB
subgraph Host["Host Machine<br/>Docker Compose Environment"]
Docker["🐳 Docker Engine"]
end
subgraph Services["Services & Containers"]
subgraph Frontend_svc["Frontend Service"]
NJS["Next.js 14<br/>Container<br/>Port: 3000<br/>Volume: ./frontend"]
end
subgraph API_svc["Backend API Service"]
NESTJS["NestJS<br/>Container<br/>Port: 3001<br/>Volume: ./backend<br/>Env: DB_HOST,<br/>BLOCKCHAIN_RPC"]
end
subgraph Database_svc["Database Service"]
PG["PostgreSQL 15<br/>Container<br/>Port: 5432<br/>Volume: postgres_data<br/>POSTGRES_DB: goa_gel<br/>POSTGRES_USER: gel_user"]
end
subgraph Cache_svc["Cache Service"]
REDIS["Redis 7<br/>Container<br/>Port: 6379<br/>Volume: redis_data"]
end
subgraph Storage_svc["File Storage Service"]
MINIO["MinIO<br/>Container<br/>Port: 9000 API<br/>Port: 9001 Console<br/>Volume: minio_data<br/>Access: minioadmin<br/>Secret: minioadmin"]
end
subgraph Blockchain_svc["Blockchain Network"]
BESU1["Besu Validator 1<br/>Container<br/>Port: 8545 RPC<br/>Port: 30303 P2P<br/>Volume: besu_data_1"]
BESU2["Besu Validator 2<br/>Container<br/>Port: 8546 RPC<br/>Port: 30304 P2P<br/>Volume: besu_data_2"]
BESU3["Besu Validator 3<br/>Container<br/>Port: 8547 RPC<br/>Port: 30305 P2P<br/>Volume: besu_data_3"]
BESU4["Besu Validator 4<br/>Container<br/>Port: 8548 RPC<br/>Port: 30306 P2P<br/>Volume: besu_data_4"]
end
subgraph Monitoring_svc["Monitoring & Logging"]
PROMETHEUS["Prometheus<br/>Port: 9090"]
GRAFANA["Grafana<br/>Port: 3000 Alt<br/>Volume: grafana_storage"]
end
end
subgraph Network["Docker Network"]
COMPOSE_NET["gel-network<br/>Driver: bridge"]
end
subgraph Volumes["Named Volumes"]
PG_VOL["postgres_data"]
REDIS_VOL["redis_data"]
MINIO_VOL["minio_data"]
BESU_VOL1["besu_data_1"]
BESU_VOL2["besu_data_2"]
BESU_VOL3["besu_data_3"]
BESU_VOL4["besu_data_4"]
GRAFANA_VOL["grafana_storage"]
end
subgraph Config["Configuration Files"]
COMPOSE["docker-compose.yml"]
ENV[".env<br/>BLOCKCHAIN_RPC<br/>DB_PASSWORD<br/>API_SECRET_KEY"]
BESU_CONFIG["besu/config.toml<br/>genesis.json<br/>ibft_config.toml"]
end
Docker -->|Run| Services
Services -->|Connect via| COMPOSE_NET
NJS -->|HTTP Client| NESTJS
NESTJS -->|SQL Query| PG
NESTJS -->|Cache| REDIS
NESTJS -->|S3 API| MINIO
NESTJS -->|RPC Call| BESU1
BESU1 -->|Peer| BESU2
BESU1 -->|Peer| BESU3
BESU1 -->|Peer| BESU4
BESU2 -->|Peer| BESU3
BESU2 -->|Peer| BESU4
BESU3 -->|Peer| BESU4
PG -->|Store| PG_VOL
REDIS -->|Store| REDIS_VOL
MINIO -->|Store| MINIO_VOL
BESU1 -->|Store| BESU_VOL1
BESU2 -->|Store| BESU_VOL2
BESU3 -->|Store| BESU_VOL3
BESU4 -->|Store| BESU_VOL4
GRAFANA -->|Store| GRAFANA_VOL
PROMETHEUS -->|Scrape| NESTJS
PROMETHEUS -->|Scrape| BESU1
GRAFANA -->|Query| PROMETHEUS
ENV -->|Configure| NESTJS
ENV -->|Configure| PG
BESU_CONFIG -->|Configure| BESU1
BESU_CONFIG -->|Configure| BESU2
BESU_CONFIG -->|Configure| BESU3
BESU_CONFIG -->|Configure| BESU4
style Host fill:#1f2937,stroke:#111827,stroke-width:2px,color:#fff
style Services fill:#374151,stroke:#1f2937,stroke-width:2px,color:#fff
style Blockchain_svc fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff
style Network fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff
style Volumes fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff
style Config fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
mermaid.contentLoaded();
</script>
</body>
</html>

View File

@@ -1,102 +0,0 @@
graph TB
subgraph Host["Host Machine<br/>Docker Compose Environment"]
Docker["🐳 Docker Engine"]
end
subgraph Services["Services & Containers"]
subgraph Frontend_svc["Frontend Service"]
NJS["Next.js 14<br/>Container<br/>Port: 3000<br/>Volume: ./frontend"]
end
subgraph API_svc["Backend API Service"]
NESTJS["NestJS<br/>Container<br/>Port: 3001<br/>Volume: ./backend<br/>Env: DB_HOST,<br/>BLOCKCHAIN_RPC"]
end
subgraph Database_svc["Database Service"]
PG["PostgreSQL 15<br/>Container<br/>Port: 5432<br/>Volume: postgres_data<br/>POSTGRES_DB: goa_gel<br/>POSTGRES_USER: gel_user"]
end
subgraph Cache_svc["Cache Service"]
REDIS["Redis 7<br/>Container<br/>Port: 6379<br/>Volume: redis_data"]
end
subgraph Storage_svc["File Storage Service"]
MINIO["MinIO<br/>Container<br/>Port: 9000 API<br/>Port: 9001 Console<br/>Volume: minio_data<br/>Access: minioadmin<br/>Secret: minioadmin"]
end
subgraph Blockchain_svc["Blockchain Network"]
BESU1["Besu Validator 1<br/>Container<br/>Port: 8545 RPC<br/>Port: 30303 P2P<br/>Volume: besu_data_1"]
BESU2["Besu Validator 2<br/>Container<br/>Port: 8546 RPC<br/>Port: 30304 P2P<br/>Volume: besu_data_2"]
BESU3["Besu Validator 3<br/>Container<br/>Port: 8547 RPC<br/>Port: 30305 P2P<br/>Volume: besu_data_3"]
BESU4["Besu Validator 4<br/>Container<br/>Port: 8548 RPC<br/>Port: 30306 P2P<br/>Volume: besu_data_4"]
end
subgraph Monitoring_svc["Monitoring & Logging"]
PROMETHEUS["Prometheus<br/>Port: 9090"]
GRAFANA["Grafana<br/>Port: 3000 Alt<br/>Volume: grafana_storage"]
end
end
subgraph Network["Docker Network"]
COMPOSE_NET["gel-network<br/>Driver: bridge"]
end
subgraph Volumes["Named Volumes"]
PG_VOL["postgres_data"]
REDIS_VOL["redis_data"]
MINIO_VOL["minio_data"]
BESU_VOL1["besu_data_1"]
BESU_VOL2["besu_data_2"]
BESU_VOL3["besu_data_3"]
BESU_VOL4["besu_data_4"]
GRAFANA_VOL["grafana_storage"]
end
subgraph Config["Configuration Files"]
COMPOSE["docker-compose.yml"]
ENV[".env<br/>BLOCKCHAIN_RPC<br/>DB_PASSWORD<br/>API_SECRET_KEY"]
BESU_CONFIG["besu/config.toml<br/>genesis.json<br/>ibft_config.toml"]
end
Docker -->|Run| Services
Services -->|Connect via| COMPOSE_NET
NJS -->|HTTP Client| NESTJS
NESTJS -->|SQL Query| PG
NESTJS -->|Cache| REDIS
NESTJS -->|S3 API| MINIO
NESTJS -->|RPC Call| BESU1
BESU1 -->|Peer| BESU2
BESU1 -->|Peer| BESU3
BESU1 -->|Peer| BESU4
BESU2 -->|Peer| BESU3
BESU2 -->|Peer| BESU4
BESU3 -->|Peer| BESU4
PG -->|Store| PG_VOL
REDIS -->|Store| REDIS_VOL
MINIO -->|Store| MINIO_VOL
BESU1 -->|Store| BESU_VOL1
BESU2 -->|Store| BESU_VOL2
BESU3 -->|Store| BESU_VOL3
BESU4 -->|Store| BESU_VOL4
GRAFANA -->|Store| GRAFANA_VOL
PROMETHEUS -->|Scrape| NESTJS
PROMETHEUS -->|Scrape| BESU1
GRAFANA -->|Query| PROMETHEUS
ENV -->|Configure| NESTJS
ENV -->|Configure| PG
BESU_CONFIG -->|Configure| BESU1
BESU_CONFIG -->|Configure| BESU2
BESU_CONFIG -->|Configure| BESU3
BESU_CONFIG -->|Configure| BESU4
style Host fill:#1f2937,stroke:#111827,stroke-width:2px,color:#fff
style Services fill:#374151,stroke:#1f2937,stroke-width:2px,color:#fff
style Blockchain_svc fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff
style Network fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff
style Volumes fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff
style Config fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff

View File

@@ -1,5 +1,22 @@
version: '3.8' version: '3.8'
# ==============================================================================
# Goa GEL Platform - Docker Compose
# ==============================================================================
#
# Quick Start (no config needed):
# docker-compose up -d
#
# Production: Copy .env.example to .env and update security values
#
# Services:
# - Frontend: http://localhost:4200
# - API: http://localhost:3001
# - Blockscout: http://localhost:4000
# - MinIO: http://localhost:9001 (admin console)
#
# ==============================================================================
services: services:
# ================================ # ================================
# PostgreSQL Database # PostgreSQL Database
@@ -11,9 +28,9 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
- POSTGRES_DB=goa_gel_platform POSTGRES_DB: goa_gel_platform
- POSTGRES_USER=postgres POSTGRES_USER: postgres
- POSTGRES_PASSWORD=postgres_secure_password POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-postgres_dev_password}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:
@@ -55,8 +72,8 @@ services:
- "9000:9000" - "9000:9000"
- "9001:9001" - "9001:9001"
environment: environment:
- MINIO_ROOT_USER=minioadmin MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
- MINIO_ROOT_PASSWORD=minioadmin_secure MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minio_dev_password}
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
@@ -115,7 +132,7 @@ services:
environment: environment:
POSTGRES_DB: blockscout POSTGRES_DB: blockscout
POSTGRES_USER: blockscout POSTGRES_USER: blockscout
POSTGRES_PASSWORD: blockscout_secure POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-blockscout_dev_password}
volumes: volumes:
- blockscout_db_data:/var/lib/postgresql/data - blockscout_db_data:/var/lib/postgresql/data
networks: networks:
@@ -136,7 +153,7 @@ services:
ports: ports:
- "4000:4000" - "4000:4000"
environment: environment:
DATABASE_URL: postgresql://blockscout:blockscout_secure@blockscout-db:5432/blockscout DATABASE_URL: postgresql://blockscout:${DATABASE_PASSWORD:-blockscout_dev_password}@blockscout-db:5432/blockscout
ETHEREUM_JSONRPC_VARIANT: besu ETHEREUM_JSONRPC_VARIANT: besu
ETHEREUM_JSONRPC_HTTP_URL: http://besu-node-1:8545 ETHEREUM_JSONRPC_HTTP_URL: http://besu-node-1:8545
ETHEREUM_JSONRPC_WS_URL: ws://besu-node-1:8546 ETHEREUM_JSONRPC_WS_URL: ws://besu-node-1:8546
@@ -155,7 +172,7 @@ services:
POOL_SIZE: 80 POOL_SIZE: 80
POOL_SIZE_API: 10 POOL_SIZE_API: 10
ECTO_USE_SSL: "false" ECTO_USE_SSL: "false"
SECRET_KEY_BASE: RMgI4C1HSkxsEjdhtGMfwAHfyT6CKWXOgzCboJflfSm4jeAlic52io05KB6mqzc5 SECRET_KEY_BASE: ${BLOCKSCOUT_SECRET_KEY_BASE:-RMgI4C1HSkxsEjdhtGMfwAHfyT6CKWXOgzCboJflfSm4jeAlic52io05KB6mqzc5}
PORT: 4000 PORT: 4000
DISABLE_EXCHANGE_RATES: "true" DISABLE_EXCHANGE_RATES: "true"
SHOW_TXS_CHART: "true" SHOW_TXS_CHART: "true"
@@ -188,29 +205,40 @@ services:
ports: ports:
- "3001:3001" - "3001:3001"
environment: environment:
- NODE_ENV=production # Application
- PORT=3001 NODE_ENV: ${NODE_ENV:-production}
- DATABASE_HOST=postgres PORT: 3001
- DATABASE_PORT=5432 # CORS - set to frontend URL for remote access
- DATABASE_NAME=goa_gel_platform CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:4200}
- DATABASE_USER=postgres # Database (must match postgres service)
- DATABASE_PASSWORD=postgres_secure_password DATABASE_HOST: postgres
- REDIS_HOST=redis DATABASE_PORT: 5432
- REDIS_PORT=6379 DATABASE_NAME: goa_gel_platform
- MINIO_ENDPOINT=minio DATABASE_USER: postgres
- MINIO_PORT=9000 DATABASE_PASSWORD: ${DATABASE_PASSWORD:-postgres_dev_password}
- MINIO_ACCESS_KEY=minioadmin # Redis
- MINIO_SECRET_KEY=minioadmin_secure REDIS_HOST: redis
- MINIO_BUCKET_DOCUMENTS=goa-gel-documents REDIS_PORT: 6379
- BESU_RPC_URL=http://besu-node-1:8545 # MinIO (must match minio service)
- BESU_CHAIN_ID=1337 MINIO_ENDPOINT: minio
- BESU_NETWORK_ID=2024 MINIO_PORT: 9000
- CONTRACT_ADDRESS_LICENSE_NFT=${CONTRACT_ADDRESS_LICENSE_NFT:-} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
- CONTRACT_ADDRESS_APPROVAL_MANAGER=${CONTRACT_ADDRESS_APPROVAL_MANAGER:-} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minio_dev_password}
- CONTRACT_ADDRESS_DEPARTMENT_REGISTRY=${CONTRACT_ADDRESS_DEPARTMENT_REGISTRY:-} MINIO_BUCKET_DOCUMENTS: goa-gel-documents
- CONTRACT_ADDRESS_WORKFLOW_REGISTRY=${CONTRACT_ADDRESS_WORKFLOW_REGISTRY:-} # Blockchain
- PLATFORM_WALLET_PRIVATE_KEY=${PLATFORM_WALLET_PRIVATE_KEY:-} BESU_RPC_URL: http://besu-node-1:8545
- JWT_SECRET=${JWT_SECRET:-your-super-secure-jwt-secret-key-min-32-chars-long} BESU_CHAIN_ID: 1337
BESU_NETWORK_ID: 2024
# Smart Contracts (populated after blockchain contract deployment)
CONTRACT_ADDRESS_LICENSE_NFT: ${CONTRACT_ADDRESS_LICENSE_NFT:-}
CONTRACT_ADDRESS_APPROVAL_MANAGER: ${CONTRACT_ADDRESS_APPROVAL_MANAGER:-}
CONTRACT_ADDRESS_DEPARTMENT_REGISTRY: ${CONTRACT_ADDRESS_DEPARTMENT_REGISTRY:-}
CONTRACT_ADDRESS_WORKFLOW_REGISTRY: ${CONTRACT_ADDRESS_WORKFLOW_REGISTRY:-}
PLATFORM_WALLET_PRIVATE_KEY: ${PLATFORM_WALLET_PRIVATE_KEY:-}
# Security
JWT_SECRET: ${JWT_SECRET:-dev_jwt_secret_change_in_production_min32chars}
# Misc
FORCE_RESEED: ${FORCE_RESEED:-false}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -223,7 +251,6 @@ services:
networks: networks:
- goa-gel-network - goa-gel-network
volumes: volumes:
- ./backend/.env:/app/.env
- api_data:/app/data - api_data:/app/data
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/v1/health"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/v1/health"]
@@ -243,6 +270,11 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4200:80" - "4200:80"
environment:
# Runtime API URL - browsers need the public/external URL to reach the API
# For local: http://localhost:3001/api/v1
# For remote: http://<server-ip>:3001/api/v1 or https://api.yourdomain.com/api/v1
API_BASE_URL: ${API_BASE_URL:-http://localhost:3001/api/v1}
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
@@ -255,7 +287,7 @@ services:
retries: 3 retries: 3
# ================================ # ================================
# Documentation Service # Documentation Service (Optional)
# ================================ # ================================
documentation: documentation:
build: build:
@@ -267,6 +299,8 @@ services:
- "8080:80" - "8080:80"
networks: networks:
- goa-gel-network - goa-gel-network
profiles:
- docs # Only starts with: docker-compose --profile docs up
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost/"] test: ["CMD", "wget", "--spider", "-q", "http://localhost/"]
interval: 30s interval: 30s

View File

@@ -1,408 +0,0 @@
# Claude Code Prompt: Frontend Overhaul — Blockchain Document Verification Platform (Angular)
## Context
You are working on the **Blockchain-based Document Verification Platform** for the **Government of Goa, India**. This is an Angular project that is already integrated with backend APIs but has significant UI/UX issues and buggy code. The backend APIs are being fixed in a parallel terminal — your job is **exclusively the frontend**.
This platform handles multi-department approval workflows for licenses/permits (starting with Resort License POC). Departments include Fire Department, Tourism Department, and Municipality. Each department has a custodial blockchain wallet managed by the platform.
**This is a demo-critical project. The UI must look world-class — think enterprise crypto/Web3 dashboard meets government platform. We need to impress government stakeholders.**
---
## Your Mission
### 1. Audit & Fix the Existing Angular Codebase
- Scan the entire frontend project for errors, broken imports, misconfigured modules, and failing builds
- Fix all TypeScript compilation errors, template errors, and runtime issues
- Ensure `ng serve` runs cleanly with zero errors and zero warnings
- Fix all existing API integrations — ensure HTTP calls, interceptors, auth headers (API Key + X-Department-Code), and error handling are working correctly
- Fix any broken routing, guards, lazy loading issues
### 2. Complete UI/UX Overhaul — World-Class Design (DBIM & GIGW 3.0 Compliant)
**⚠️ MANDATORY COMPLIANCE: India's Digital Brand Identity Manual (DBIM v3.0, Jan 2025) & GIGW 3.0**
This is a Government of India / Government of Goa platform. It MUST comply with the official DBIM (Digital Brand Identity Manual) published by MeitY and the GIGW 3.0 (Guidelines for Indian Government Websites and Apps). Below are the extracted requirements:
**Color Palette — DBIM Primary Colour Group + Functional Palette:**
The DBIM requires each government org to pick ONE primary colour group. For a blockchain/technology platform under Government of Goa, select the **Blue colour group** from the DBIM primary palette. This aligns with the "Deep Blue" (#1D0A69) used for Gov.In websites and gives the modern tech feel we need.
```
DBIM PRIMARY COLOUR GROUP — BLUE (selected for this project):
├── Key Colour (darkest): Use for footer background, primary headers, sidebar
├── Mid variants: Use for primary buttons, active states, links
├── Light variants: Use for hover states, card accents, subtle backgrounds
DBIM FUNCTIONAL PALETTE (mandatory for all govt platforms):
├── Background Primary: #FFFFFF (Inclusive White) — page backgrounds
├── Background Secondary: #EBEAEA (Linen) — highlight sections, card backgrounds, quote blocks
├── Text on Light BG: #150202 (Deep Earthy Brown) — NOT black, this is the official text color
├── Text on Dark BG: #FFFFFF (Inclusive White)
├── State Emblem on Light: #000000 (Black)
├── State Emblem on Dark: #FFFFFF (White)
├── Deep Blue (Gov.In): #1D0A69 — distinct government identity color
├── Success Status: #198754 (Liberty Green) — approved, confirmed
├── Warning Status: #FFC107 (Mustard Yellow) — pending, in-review
├── Error Status: #DC3545 (Coral Red) — rejected, failed
├── Information Status: #0D6EFD (Blue) — also for hyperlinks
├── Grey 01: #C6C6C6
├── Grey 02: #8E8E8E
├── Grey 03: #606060
```
**IMPORTANT COLOR RULES:**
- The DBIM allows gradients of any two variants from the selected colour group
- Hyperlinks must use #0D6EFD (DBIM Blue) or the key colour of the selected group
- Footer MUST be in the key colour (darkest shade) of the selected primary group
- Status colors are FIXED by DBIM — do not use custom colors for success/warning/error
- Text color on light backgrounds must be #150202 (Deep Earthy Brown), NOT pure black
**Design Philosophy:**
- **Light theme primary** with professional government institutional aesthetics (DBIM mandates #FFFFFF as primary page background). Use the blue colour group for headers, sidebars, and accent areas to create the modern tech/blockchain feel
- For internal dashboards (department portal, admin portal), you MAY use a darker sidebar/nav with light content area — this is common in enterprise SaaS and not prohibited by DBIM
- Clean, modern, data-dense dashboard layouts
- Subtle card elevation with #EBEAEA (Linen) backgrounds for card sections
- Goa Government State Emblem + "Government of Goa" header (MANDATORY — see Logo section below)
- National Emblem of India (Ashoka Lions) at the top per DBIM lockup guidelines
- Smooth micro-animations (route transitions, card hovers, status changes)
- Fully responsive (desktop-first, but mobile must work for demo)
- WCAG 2.1 AA compliant contrast ratios (GIGW 3.0 mandates this)
**Logo & Header — DBIM Mandatory Requirements:**
- Since this is a State Government platform, use **DBIM Lockup Style appropriate for State Government**
- Display the **Goa State Emblem** (Vriksha Deep / diya lamp with coconut leaves, Ashoka Lions on top, supported by two hands) prominently in the header
- Sanskrit motto: "सर्वे भद्राणि पश्यन्तु मा कश्चिद् दुःखमाप्नुयात्" (may appear in emblem)
- Text: "Government of Goa" below or beside the emblem
- Platform name: "Blockchain Document Verification Platform" as secondary text
- Header must include: Logo lockup, search bar, language selection (अ | A), accessibility controls, navigation menu
- On dark backgrounds use white emblem; on white backgrounds use black emblem
**Footer — DBIM Mandatory Elements:**
- Footer MUST be in the darkest shade of the selected blue colour group
- Must include: Website Policies, Sitemap, Related Links, Help, Feedback, Social Media Links, Last Updated On
- Must state lineage: "This platform belongs to Government of Goa, India"
- Powered by / Technology credits may be included
**Typography — DBIM Mandatory:**
- Font: **Noto Sans** (DBIM mandates this for ALL Government of India digital platforms — it supports all Indian scripts)
- Desktop scale: H1=36px, H2=24px, H3/Subtitle=20px, P1=16px, P2=14px, Small=12px
- Mobile scale: H1=24px, H2=20px, H3=16px, P1=14px, P2=12px, Small=10px
- Weights: Bold, Semi Bold, Medium, Regular
- Body text must be left-aligned
- Tables: left-aligned text, right-aligned numbers, center-aligned column names
- Line height: 1.2 to 1.5 times the type size
- NO all-caps for long sentences or paragraphs (DBIM rule)
- British English throughout (GIGW 3.0 requirement)
**Icons — DBIM Guidelines:**
- Use icons from the DBIM Toolkit icon bank (https://dbimtoolkit.digifootprint.gov.in) where available
- Pick ONE icon style and stick with it: either Line icons or Filled icons (not mixed)
- Icons must be in the key colour (darkest shade) of selected group or Inclusive White
- Available sizes: standardized with 2px padding as per DBIM spec
- For icons not in DBIM toolkit, use **Lucide icons** or **Phosphor icons** but maintain consistent style
- Icons for significant actions MUST have text labels alongside them
- Include tooltips for all icons
**Accessibility — GIGW 3.0 / WCAG 2.1 AA Mandatory:**
- Screen reader support with proper ARIA labels
- Keyboard navigation for all interactive elements
- Skip to main content link
- Accessibility controls in header (text size adjustment, contrast toggle)
- Alt text for all images (max 140 characters, no "image of..." prefixes)
- Proper heading hierarchy (H1, H2, H3)
- Sufficient color contrast (4.5:1 for normal text, 3:1 for large text)
- No content conveyed through color alone — use icons + text + color
**Forms — DBIM Form Guidelines:**
- Instructions at the start of each form
- Fields arranged vertically (one per line)
- Mandatory fields marked with asterisk (*) or "Required"
- Multi-step forms for complex workflows (stepper UI)
- Labels above fields (not inline for complex forms), clickable labels
- Primary buttons prominent, secondary buttons less visual weight
- Validation feedback before submission
- Radio buttons instead of dropdowns for ≤6 options
**CSS Framework & Tooling:**
- Use **Tailwind CSS** as the primary utility framework — configure custom theme with DBIM color tokens
- Extend Tailwind config with the exact DBIM hex values above as custom colors (e.g., `dbim-brown: '#150202'`, `dbim-linen: '#EBEAEA'`, `dbim-success: '#198754'`, etc.)
- Use **Angular Material** or **PrimeNG** for complex components (data tables, dialogs, steppers, file upload) — pick whichever is already partially in use, or install PrimeNG if starting fresh
- Custom CSS/SCSS for subtle card effects, gradients within the blue colour group, and animations
- Google Fonts: **Noto Sans** (mandatory per DBIM) — load all needed weights (400, 500, 600, 700)
**Balancing Government Compliance with Modern Blockchain Aesthetic:**
The DBIM gives us a professional, trustworthy foundation. Layer the blockchain/Web3 feel through:
- Data visualization (charts, timelines, transaction hashes)
- Wallet cards with gradient backgrounds (using blue colour group variants)
- Blockchain transaction badges, hash displays, block explorers
- Modern micro-animations and transitions
- Dense, information-rich dashboards
- The blue colour group naturally gives a tech/blockchain feel while being DBIM compliant
### 3. Core Pages & Components to Build/Rebuild
#### A. **Login / Auth Page**
- Mock login for applicants
- Department login with API Key
- Admin login
- Animated background (subtle particle/grid animation or gradient mesh)
- Goa Government emblem + platform name prominent
#### B. **Applicant Portal**
**Dashboard:**
- Summary cards: Total Requests, Pending, Approved, Rejected (with animated counters)
- Recent activity timeline
- Quick action: "New Request" button (prominent CTA)
**New Request Page:**
- Multi-step form (stepper UI):
1. Select License Type (Resort License for POC)
2. Fill Application Details (metadata form)
3. Upload Required Documents (drag-drop file upload with preview)
4. Review & Submit
- Each step validates before proceeding
- Document upload shows file name, type, size, upload progress, and hash after upload
**Request Detail Page (`/requests/:id`):**
- Full request info card
- **Document section**: List of uploaded documents with version history, download links, and on-chain hash display
- **Approval Timeline**: Vertical timeline component showing each department's approval status, remarks, timestamps, and blockchain tx hash (clickable, links to block explorer or shows tx details modal)
- **Status Badge**: Large, prominent status indicator (DRAFT, SUBMITTED, IN_REVIEW, APPROVED, REJECTED, etc.) with appropriate colors and icons
- Action buttons: Upload New Document, Withdraw Request (if applicable)
#### C. **Department Portal**
**Dashboard:**
- Pending requests count (large, prominent number with pulse animation if > 0)
- **Department Wallet Section** (NEW — CRITICAL):
- Display department's Ethereum wallet address (truncated with copy button)
- Wallet balance (ETH or native token)
- Recent transactions list (approval txs sent from this wallet)
- Visual wallet card with gradient background (like a crypto wallet card)
- Queue of pending requests in a data table with sorting, filtering, search
- Recent approvals/rejections list
**Request Review Page:**
- Split layout: Documents on the left (with viewer/preview), approval form on the right
- Document viewer: PDF preview, image preview inline
- Action buttons: Approve, Reject, Request Changes — each with remarks textarea
- Show which documents this department is required to review (highlighted)
- Show other departments' approval statuses for this request
- Confirmation dialog before submitting approval (shows tx will be recorded on-chain)
- Should give fully a crypto wallet experience to the department portal.
#### D. **Admin Portal**
**Dashboard:**
- System-wide stats: Total Requests, Active Workflows, Registered Departments, Blockchain Stats (block height, total txs, node count)
- Stat cards with sparkline mini-charts or progress indicators
- Recent system activity feed
**Department Management:**
- CRUD table for departments
- Each department row shows: Name, Code, Wallet Address, API Key status, Webhook status, Active/Inactive toggle
- Department detail page with wallet info, approval history, performance metrics
**Workflow Builder Page (`/admin/workflows/builder`):**
- **Visual drag-and-drop workflow builder** using a library like `ngx-graph`, `elkjs`, or a custom implementation with Angular CDK drag-drop
- Canvas where you can:
- Add stages (nodes)
- Connect stages with arrows (edges) showing sequential or parallel flow
- Configure each stage: name, execution type (SEQUENTIAL/PARALLEL), departments assigned, required documents, completion criteria (ALL/ANY/THRESHOLD), rejection behavior, timeout
- Stage nodes should be visually distinct (colored by status type, department icons)
- Toolbar: Add Stage, Delete, Connect, Zoom, Pan, Auto-layout
- Right sidebar: Stage configuration panel (appears when a stage is selected)
- Preview mode: Shows how a request would flow through the workflow
- Save workflow as JSON, load existing workflows
**Audit Logs:**
- Searchable, filterable table of all system actions
- Filter by entity type, action, actor, date range
- Each row expandable to show old/new values diff
**Blockchain Explorer (Mini):**
- Recent blocks list
- Recent transactions list with type badges (MINT_NFT, APPROVAL, DOC_UPDATE, etc)
- Transaction detail view: from, to, block number, gas used, status, related entity link
#### E. **Shared Components**
- **Blockchain Transaction Badge**: Shows tx hash (truncated), status (confirmed/pending/failed), clickable to show details
- **Status Badge Component**: Reusable, colored badges for all status types
- **Wallet Card Component**: Reusable wallet display with address, balance, copy, QR
- **Document Hash Display**: Shows hash with copy button, verification icon
- **Approval Timeline Component**: Vertical timeline with department avatars, status, remarks
- **Notification Toast System**: For webhook events, approval updates, errors
- **Loading Skeletons**: Shimmer loading states for all data-heavy pages
- **Empty States**: Illustrated empty states (no requests yet, no pending items, etc.)
### 4. Wallet Section — Detailed Requirements
This is a key differentiator for the demo. Every department has a custodial Ethereum wallet.
**Department Wallet Dashboard Widget:**
```
┌─────────────────────────────────────────────┐
│ 🔷 Fire Department Wallet │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Address: 0x1234...abcd 📋 │ │
│ │ Balance: 0.045 ETH │ │
│ │ Transactions: 23 │ │
│ │ Last Active: 2 hours ago │ │
│ └─────────────────────────────────────┘ │
│ │
│ Recent Transactions │
│ ├─ ✅ Approval TX 0xabc... 2h ago │
│ ├─ ✅ Approval TX 0xdef... 1d ago │
│ └─ ❌ Rejected TX 0x789... 3d ago │
│ │
│ [View All Transactions] [View on Explorer]│
└─────────────────────────────────────────────┘
```
**Admin Wallet Overview:**
- Table of all department wallets with balances
- Total platform wallet stats
- Fund department wallets action (for gas)
### 5. Technical Requirements
- **State Management**: Use NgRx or Angular Signals for complex state (requests list, workflow builder state, wallet data). Use simple services with BehaviorSubjects for simpler state.
- **API Integration**: All API calls should go through proper Angular services with:
- HTTP interceptors for auth headers
- Global error handling interceptor
- Loading state management
- Retry logic for blockchain tx endpoints (which may take a few seconds)
- Proper TypeScript interfaces for ALL API request/response types
- **Routing**: Lazy-loaded feature modules for each portal (applicant, department, admin)
- **Guards**: Auth guards, role-based route guards
- **Real-time**: If WebSocket is available, use it for live status updates. Otherwise, implement polling for pending request updates on department dashboard.
- **Responsive**: CSS Grid + Tailwind responsive utilities. Sidebar collapses on mobile.
- **Accessibility**: ARIA labels, keyboard navigation, proper contrast ratios
- **Performance**: Virtual scrolling for long lists, lazy loading images, OnPush change detection where possible
### 6. File/Folder Structure Target
```
src/
├── app/
│ ├── core/
│ │ ├── interceptors/ # Auth, error, loading interceptors
│ │ ├── guards/ # Auth, role guards
│ │ ├── services/ # Auth, blockchain, wallet services
│ │ ├── models/ # TypeScript interfaces for all entities
│ │ └── constants/ # API URLs, status enums, colors
│ ├── shared/
│ │ ├── components/ # Reusable components (status-badge, wallet-card, tx-badge, etc.)
│ │ ├── pipes/ # Truncate address, format date, etc.
│ │ ├── directives/ # Click-outside, tooltip, etc.
│ │ └── layouts/ # Shell layout with sidebar + topbar
│ ├── features/
│ │ ├── auth/ # Login pages
│ │ ├── applicant/ # Applicant portal (dashboard, requests, documents)
│ │ ├── department/ # Department portal (dashboard, review, wallet)
│ │ ├── admin/ # Admin portal (dashboard, departments, workflows, explorer)
│ │ └── workflow-builder/ # Visual workflow builder module
│ ├── app.component.ts
│ ├── app.routes.ts
│ └── app.config.ts
├── assets/
│ ├── images/ # Goa emblem, logos, illustrations
│ ├── icons/ # Custom SVG icons if needed
│ └── animations/ # Lottie files if used
├── styles/
│ ├── tailwind.css # Tailwind imports
│ ├── _variables.scss # Color palette, spacing, typography tokens
│ ├── _glassmorphism.scss # Glass card mixins
│ ├── _animations.scss # Keyframe animations
│ └── global.scss # Global styles, resets
└── environments/
├── environment.ts
└── environment.prod.ts
```
### 7. API Endpoints Reference
- refer the backend api docs for the api endpoints.
**Auth Headers for Department APIs:**
```
X-API-Key: {department_api_key}
X-Department-Code: {department_code} // e.g., "FIRE_DEPT"
```
### 8. Priority Order
Execute in this order:
1. **Fix build errors** — get `ng serve` running clean
2. **Install and configure Tailwind CSS** (if not already)
3. **Create the shared layout** (dark theme shell with sidebar, topbar)
4. **Build shared components** (status badge, wallet card, tx badge, etc.)
5. **Rebuild Applicant Portal** pages
6. **Rebuild Department Portal** pages (with wallet section)
7. **Rebuild Admin Portal** pages
8. **Build Workflow Builder** (most complex, do last)
9. **Polish**: animations, loading states, empty states, error states
10. **Test all API integrations** end-to-end
### 9. Important Notes
- **DO NOT** change backend API contracts — the backend team is fixing those separately
- **DO** add proper error handling and fallback UI if an API is temporarily unavailable (show friendly error states, not blank pages)
- **DO** use mock/dummy data in the UI for any endpoints not yet ready, clearly mark with `// TODO: Replace with real API` comments. mock data should be realistic and should look like real data.
- **DO** make the wallet section functional even if the wallet API isn't ready — show realistic mock data with a clean UI- double check backend API docs for wallet API endpoints.
- **DO** ensure all environment-specific values (API base URL, etc.) come from environment files
- The demo scenario is: Applicant creates request → uploads docs → submits → departments review → approve/reject → NFT minted. **Every step of this flow must work and look incredible.**
### 10. Design References & Compliance Resources
**MANDATORY Government References (read these before designing):**
- DBIM v3.0 (Digital Brand Identity Manual): https://dbimtoolkit.digifootprint.gov.in/static/uploads/2025/10/8bc5c5028b2396be4cc07d0acba47ff7.pdf
- DBIM Toolkit (icons, templates, resources): https://dbimtoolkit.digifootprint.gov.in
- GIGW 3.0 (Guidelines for Indian Government Websites): https://guidelines.india.gov.in
- Goa State Emblem reference: https://dip.goa.gov.in/state-emblem/
**For Visual/UX Inspiration (adapt to DBIM constraints):**
- India's gov.in portal (reference for government header/footer pattern)
- Etherscan (block explorer aesthetics — transaction lists, hash displays)
- Aave/Uniswap dashboards (DeFi dashboard data density)
- Linear.app (clean enterprise SaaS patterns)
- Vercel dashboard (developer-focused, clean layout)
**Key Tailwind Config Additions for DBIM Compliance:**
```typescript
// tailwind.config.js — extend with DBIM tokens
theme: {
extend: {
colors: {
'dbim': {
'white': '#FFFFFF', // Inclusive White
'linen': '#EBEAEA', // Background secondary
'brown': '#150202', // Deep Earthy Brown - text color
'black': '#000000', // Emblem on light BG
'deep-blue': '#1D0A69', // Gov.In identity blue
'success': '#198754', // Liberty Green
'warning': '#FFC107', // Mustard Yellow
'error': '#DC3545', // Coral Red
'info': '#0D6EFD', // Blue - info & hyperlinks
'grey-1': '#C6C6C6',
'grey-2': '#8E8E8E',
'grey-3': '#606060',
}
},
fontFamily: {
'sans': ['Noto Sans', 'sans-serif'], // DBIM mandatory font
}
}
}
```
The goal: When a government official sees this, they should think "this is the future of governance" — modern, trustworthy, transparent, and beautiful — **while recognizing it immediately as an official Government of India platform** because it follows DBIM branding standards they've seen on other government websites.
---
**Start by scanning the existing codebase, listing all issues, then fix and rebuild systematically following the priority order above. Ask me if you need clarification on any specific API contract or business logic.**

View File

@@ -1,451 +0,0 @@
Here is Claude's plan:
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Goa-GEL Full Stack Implementation Plan
Overview
Build a complete end-to-end working system: Angular 21 frontend, Solidity smart contracts, and full integration with the existing NestJS backend.
Current State Summary
- Backend: 11 modules, 50+ endpoints, fully implemented
- Frontend: Angular 21 skeleton - models exist, no components/services/routes
- Blockchain: 4 Besu nodes running, backend services coded, NO contracts deployed
- Docker: 8 containers operational (api, postgres, redis, minio, 4 besu nodes)
---
Phase 1: Core Infrastructure (Frontend)
1.1 Core Services
Create the foundation services that all features depend on.
┌────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐
│ File │ Purpose │
├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ frontend/src/app/core/services/storage.service.ts │ LocalStorage wrapper for tokens/user data │
├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ frontend/src/app/core/services/api.service.ts │ HttpClient wrapper, handles {success,data,timestamp} response format │
├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ frontend/src/app/core/services/auth.service.ts │ Login/logout, token management, current user state │
├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ frontend/src/app/core/services/notification.service.ts │ MatSnackBar wrapper for toast notifications │
├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
│ frontend/src/app/core/services/index.ts │ Barrel export │
└────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘
Key Implementation Details:
- ApiService: Generic methods get<T>(), post<T>(), put<T>(), patch<T>(), delete<T>() that unwrap the {success, data} envelope
- AuthService: Exposes currentUser$ BehaviorSubject, isAuthenticated$, userRole$ observables
- Use environment.ts for apiBaseUrl and storage keys
1.2 HTTP Interceptors
┌─────────────────────────────────────────────────────────┬───────────────────────────────────────────────┐
│ File │ Purpose │
├─────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/core/interceptors/auth.interceptor.ts │ Adds Authorization: Bearer <token> header │
├─────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/core/interceptors/error.interceptor.ts │ Global error handling, 401 -> logout redirect │
├─────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/core/interceptors/index.ts │ Barrel export │
└─────────────────────────────────────────────────────────┴───────────────────────────────────────────────┘
1.3 Route Guards
┌────────────────────────────────────────────┬───────────────────────────────────────────────────────────┐
│ File │ Purpose │
├────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ frontend/src/app/core/guards/auth.guard.ts │ Blocks unauthenticated access, redirects to /login │
├────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ frontend/src/app/core/guards/role.guard.ts │ Blocks access based on user role (data.roles route param) │
├────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ frontend/src/app/core/guards/index.ts │ Barrel export │
└────────────────────────────────────────────┴───────────────────────────────────────────────────────────┘
1.4 Layouts
┌─────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────┐
│ File │ Purpose │
├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
│ frontend/src/app/layouts/main-layout/main-layout.component.ts │ Sidenav + toolbar shell for authenticated pages │
├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
│ frontend/src/app/layouts/main-layout/main-layout.component.html │ Template with mat-sidenav-container │
├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
│ frontend/src/app/layouts/main-layout/main-layout.component.scss │ Layout styles │
├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
│ frontend/src/app/layouts/auth-layout/auth-layout.component.ts │ Minimal centered layout for login │
├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
│ frontend/src/app/layouts/auth-layout/auth-layout.component.html │ Template │
├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
│ frontend/src/app/layouts/auth-layout/auth-layout.component.scss │ Styles │
└─────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────┘
1.5 App Configuration
┌────────────────────────────────┬───────────────────────────────────────────────┐
│ File │ Purpose │
├────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/app.config.ts │ Update to add provideHttpClient, interceptors │
├────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/app.routes.ts │ Root routes with lazy loading │
├────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/app.ts │ Update root component │
├────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/app.html │ Minimal template (just router-outlet) │
├────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/styles.scss │ Global Material theme + base styles │
└────────────────────────────────┴───────────────────────────────────────────────┘
Verification: ng serve runs without errors, hitting / redirects to /login
---
Phase 2: Authentication Feature
2.1 Auth Components
┌─────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────┐
│ File │ Purpose │
├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ frontend/src/app/features/auth/auth.routes.ts │ Auth feature routes │
├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ frontend/src/app/features/auth/login-select/login-select.component.ts │ Choose login type (Department/DigiLocker) │
├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ frontend/src/app/features/auth/department-login/department-login.component.ts │ Department login form (apiKey, departmentCode) │
├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ frontend/src/app/features/auth/department-login/department-login.component.html │ Form template │
├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ frontend/src/app/features/auth/digilocker-login/digilocker-login.component.ts │ DigiLocker login (digilockerId, name, email, phone) │
├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ frontend/src/app/features/auth/digilocker-login/digilocker-login.component.html │ Form template │
└─────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────┘
Login Flow:
1. /login -> LoginSelectComponent (two buttons: Department Login, DigiLocker Login)
2. Department: POST /auth/department/login with {apiKey, departmentCode}
3. DigiLocker: POST /auth/digilocker/login with {digilockerId, name?, email?, phone?}
4. On success: Store token, redirect to /dashboard
Test Credentials (from seed):
- Department: FIRE_SAFETY / fire_safety_api_key_12345
- Department: BUILDING_DEPT / building_dept_api_key_12345
- DigiLocker: Any digilockerId (auto-creates user)
Verification: Can login as department and applicant, token stored, redirects to dashboard
---
Phase 3: Dashboard & Requests
3.1 Shared Components
┌─────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────┐
│ File │ Purpose │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤
│ frontend/src/app/shared/components/page-header/page-header.component.ts │ Reusable page title + actions bar │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤
│ frontend/src/app/shared/components/status-badge/status-badge.component.ts │ Colored badge for request/approval status │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤
│ frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts │ MatDialog confirmation modal │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤
│ frontend/src/app/shared/components/loading-spinner/loading-spinner.component.ts │ Centered spinner │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤
│ frontend/src/app/shared/components/empty-state/empty-state.component.ts │ "No data" placeholder │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤
│ frontend/src/app/shared/components/index.ts │ Barrel export │
└─────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────┘
3.2 Dashboard Feature
┌────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────┐
│ File │ Purpose │
├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤
│ frontend/src/app/features/dashboard/dashboard.routes.ts │ Dashboard routes │
├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤
│ frontend/src/app/features/dashboard/dashboard.component.ts │ Role-based dashboard switcher │
├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤
│ frontend/src/app/features/dashboard/admin-dashboard/admin-dashboard.component.ts │ Admin stats (calls GET /admin/stats) │
├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤
│ frontend/src/app/features/dashboard/department-dashboard/department-dashboard.component.ts │ Pending approvals list │
├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤
│ frontend/src/app/features/dashboard/applicant-dashboard/applicant-dashboard.component.ts │ My requests list │
└────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────┘
Dashboard Content by Role:
- ADMIN: Platform stats cards (total requests, approvals, documents, departments), system health, recent activity
- DEPARTMENT: Pending requests needing approval, recent approvals made
- APPLICANT: My requests with status, quick action to create new request
3.3 Requests Feature
┌─────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────┐
│ File │ Purpose │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/requests.routes.ts │ Request feature routes │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/request-list/request-list.component.ts │ Paginated list with filters │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/request-list/request-list.component.html │ MatTable with pagination │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/request-detail/request-detail.component.ts │ Full request view + timeline │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/request-detail/request-detail.component.html │ Tabs: Details, Documents, Approvals, Timeline │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/request-create/request-create.component.ts │ Create new request form │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/request-create/request-create.component.html │ Stepper form │
├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ frontend/src/app/features/requests/services/request.service.ts │ Request API methods │
└─────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────┘
Request Workflow:
1. Create (DRAFT) -> Upload documents -> Submit (SUBMITTED)
2. Department reviews -> Approve/Reject/Request Changes
3. If approved by all stages -> License minted as NFT
Verification: Can create request, view list, view details, submit request
---
Phase 4: Documents & Approvals
4.1 Documents Feature
┌──────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────┐
│ File │ Purpose │
├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ frontend/src/app/features/documents/documents.routes.ts │ Document routes (nested under requests) │
├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ frontend/src/app/features/documents/document-upload/document-upload.component.ts │ File upload with drag-drop │
├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ frontend/src/app/features/documents/document-list/document-list.component.ts │ Documents table with download/delete │
├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ frontend/src/app/features/documents/document-viewer/document-viewer.component.ts │ Preview modal (PDF/images) │
├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ frontend/src/app/features/documents/services/document.service.ts │ Document API methods │
└──────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────┘
Document Types: FIRE_SAFETY_CERTIFICATE, BUILDING_PLAN, PROPERTY_OWNERSHIP, INSPECTION_REPORT, etc.
4.2 Approvals Feature
┌────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────┐
│ File │ Purpose │
├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤
│ frontend/src/app/features/approvals/approvals.routes.ts │ Approval routes │
├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤
│ frontend/src/app/features/approvals/pending-list/pending-list.component.ts │ Pending approvals for department │
├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤
│ frontend/src/app/features/approvals/approval-action/approval-action.component.ts │ Approve/Reject/Request Changes dialog │
├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤
│ frontend/src/app/features/approvals/approval-history/approval-history.component.ts │ Approval trail for a request │
├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤
│ frontend/src/app/features/approvals/services/approval.service.ts │ Approval API methods │
└────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────┘
Approval Actions:
- Approve: Remarks (min 10 chars), select reviewed documents
- Reject: Remarks, rejection reason (enum)
- Request Changes: Remarks, list required documents
Verification: Department can approve/reject requests, applicant sees updated status
---
Phase 5: Admin Features
5.1 Departments Management
┌────────────────────────────────────────────────────────────────────────────────────────┬────────────────────────┐
│ File │ Purpose │
├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ frontend/src/app/features/departments/departments.routes.ts │ Department routes │
├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ frontend/src/app/features/departments/department-list/department-list.component.ts │ Departments table │
├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ frontend/src/app/features/departments/department-form/department-form.component.ts │ Create/edit form │
├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ frontend/src/app/features/departments/department-detail/department-detail.component.ts │ Stats + actions │
├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ frontend/src/app/features/departments/services/department.service.ts │ Department API methods │
└────────────────────────────────────────────────────────────────────────────────────────┴────────────────────────┘
5.2 Workflows Management
┌────────────────────────────────────────────────────────────────────────────────────┬────────────────────────────────┐
│ File │ Purpose │
├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ frontend/src/app/features/workflows/workflows.routes.ts │ Workflow routes │
├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ frontend/src/app/features/workflows/workflow-list/workflow-list.component.ts │ Workflows table │
├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ frontend/src/app/features/workflows/workflow-form/workflow-form.component.ts │ Create/edit with stage builder │
├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ frontend/src/app/features/workflows/workflow-preview/workflow-preview.component.ts │ Visual workflow preview │
├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ frontend/src/app/features/workflows/services/workflow.service.ts │ Workflow API methods │
└────────────────────────────────────────────────────────────────────────────────────┴────────────────────────────────┘
5.3 Webhooks Management
┌───────────────────────────────────────────────────────────────────────────┬─────────────────────┐
│ File │ Purpose │
├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤
│ frontend/src/app/features/webhooks/webhooks.routes.ts │ Webhook routes │
├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤
│ frontend/src/app/features/webhooks/webhook-list/webhook-list.component.ts │ Webhooks table │
├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤
│ frontend/src/app/features/webhooks/webhook-form/webhook-form.component.ts │ Register/edit form │
├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤
│ frontend/src/app/features/webhooks/webhook-logs/webhook-logs.component.ts │ Delivery logs │
├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤
│ frontend/src/app/features/webhooks/services/webhook.service.ts │ Webhook API methods │
└───────────────────────────────────────────────────────────────────────────┴─────────────────────┘
5.4 Audit Logs
┌────────────────────────────────────────────────────────────────────────┬──────────────────────────────┐
│ File │ Purpose │
├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤
│ frontend/src/app/features/audit/audit.routes.ts │ Audit routes │
├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤
│ frontend/src/app/features/audit/audit-list/audit-list.component.ts │ Filterable audit log table │
├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤
│ frontend/src/app/features/audit/entity-trail/entity-trail.component.ts │ Timeline for specific entity │
├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤
│ frontend/src/app/features/audit/services/audit.service.ts │ Audit API methods │
└────────────────────────────────────────────────────────────────────────┴──────────────────────────────┘
Verification: Admin can manage departments, workflows, webhooks, view audit logs
---
Phase 6: Smart Contracts
6.1 Hardhat Project Setup
┌──────────────────────────────┬──────────────────────────────────┐
│ File │ Purpose │
├──────────────────────────────┼──────────────────────────────────┤
│ blockchain/package.json │ Hardhat dependencies │
├──────────────────────────────┼──────────────────────────────────┤
│ blockchain/hardhat.config.ts │ Besu network config (chain 1337) │
├──────────────────────────────┼──────────────────────────────────┤
│ blockchain/tsconfig.json │ TypeScript config │
├──────────────────────────────┼──────────────────────────────────┤
│ blockchain/.env │ Private key for deployment │
└──────────────────────────────┴──────────────────────────────────┘
6.2 Solidity Contracts
┌───────────────────────────────────────────┬─────────────────────────────────────┐
│ File │ Purpose │
├───────────────────────────────────────────┼─────────────────────────────────────┤
│ blockchain/contracts/LicenseNFT.sol │ ERC721 license tokens │
├───────────────────────────────────────────┼─────────────────────────────────────┤
│ blockchain/contracts/ApprovalManager.sol │ Approval recording │
├───────────────────────────────────────────┼─────────────────────────────────────┤
│ blockchain/contracts/DocumentChain.sol │ Document hash verification │
├───────────────────────────────────────────┼─────────────────────────────────────┤
│ blockchain/contracts/WorkflowRegistry.sol │ Workflow registration (placeholder) │
└───────────────────────────────────────────┴─────────────────────────────────────┘
LicenseNFT.sol Functions:
function mint(address to, string calldata requestId, string calldata metadataUri) public returns (uint256)
function tokenOfRequest(string calldata requestId) public view returns (uint256)
function exists(uint256 tokenId) public view returns (bool)
function ownerOf(uint256 tokenId) public view returns (address)
function revoke(uint256 tokenId) public
function isRevoked(uint256 tokenId) public view returns (bool)
function getMetadata(uint256 tokenId) public view returns (string memory)
ApprovalManager.sol Functions:
function recordApproval(string calldata requestId, address departmentAddress, uint8 status, string calldata remarksHash, string[] calldata documentHashes) public returns (bytes32)
function getRequestApprovals(string calldata requestId) public view returns (Approval[] memory)
function invalidateApproval(bytes32 approvalId) public
function verifyApproval(bytes32 approvalId, string calldata remarksHash) public view returns (bool)
function getApprovalDetails(bytes32 approvalId) public view returns (Approval memory)
DocumentChain.sol Functions:
function recordDocumentHash(string calldata requestId, string calldata documentId, string calldata hash, uint256 version) public returns (bytes32)
function verifyDocumentHash(string calldata documentId, string calldata hash) public view returns (bool)
function getDocumentHistory(string calldata documentId) public view returns (DocumentRecord[] memory)
function getLatestDocumentHash(string calldata documentId) public view returns (string memory)
6.3 Deployment
┌──────────────────────────────────┬─────────────────────────────────────────────┐
│ File │ Purpose │
├──────────────────────────────────┼─────────────────────────────────────────────┤
│ blockchain/scripts/deploy.ts │ Deploy all contracts, output addresses │
├──────────────────────────────────┼─────────────────────────────────────────────┤
│ blockchain/scripts/update-env.ts │ Update backend/.env with deployed addresses │
└──────────────────────────────────┴─────────────────────────────────────────────┘
Verification: Contracts deployed, addresses in .env, backend connects successfully
---
Phase 7: Integration & Polish
7.1 Frontend-Blockchain Integration
┌───────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────┐
│ File │ Purpose │
├───────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
│ frontend/src/app/features/requests/request-detail/blockchain-info.component.ts │ Show NFT token ID, tx hash │
├───────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
│ frontend/src/app/features/documents/document-list/verification-badge.component.ts │ Show blockchain verification status │
└───────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────┘
7.2 Final Polish
┌───────────────────┬───────────────────────────────────────────────┐
│ Task │ Description │
├───────────────────┼───────────────────────────────────────────────┤
│ Error handling │ Consistent error messages across all features │
├───────────────────┼───────────────────────────────────────────────┤
│ Loading states │ Spinners on all async operations │
├───────────────────┼───────────────────────────────────────────────┤
│ Responsive design │ Mobile-friendly sidenav collapse │
├───────────────────┼───────────────────────────────────────────────┤
│ Form validation │ Client-side validation matching backend DTOs │
├───────────────────┼───────────────────────────────────────────────┤
│ Empty states │ Meaningful messages when no data │
└───────────────────┴───────────────────────────────────────────────┘
---
File Count Summary
┌──────────────────────────────┬───────────┬────────────────┐
│ Phase │ New Files │ Modified Files │
├──────────────────────────────┼───────────┼────────────────┤
│ Phase 1: Core │ 16 │ 4 │
├──────────────────────────────┼───────────┼────────────────┤
│ Phase 2: Auth │ 8 │ 0 │
├──────────────────────────────┼───────────┼────────────────┤
│ Phase 3: Dashboard/Requests │ 18 │ 0 │
├──────────────────────────────┼───────────┼────────────────┤
│ Phase 4: Documents/Approvals │ 12 │ 0 │
├──────────────────────────────┼───────────┼────────────────┤
│ Phase 5: Admin │ 20 │ 0 │
├──────────────────────────────┼───────────┼────────────────┤
│ Phase 6: Contracts │ 10 │ 1 │
├──────────────────────────────┼───────────┼────────────────┤
│ Phase 7: Integration │ 4 │ 2 │
├──────────────────────────────┼───────────┼────────────────┤
│ Total │ ~88 │ ~7 │
└──────────────────────────────┴───────────┴────────────────┘
---
Verification Checklist
After Phase 1-2
- ng serve runs without errors
- Can navigate to /login
- Department login works with test credentials
- DigiLocker login works with any ID
- Token stored in localStorage
- Authenticated routes protected
After Phase 3-4
- Dashboard shows role-appropriate content
- Can create new license request
- Can upload documents to request
- Can submit request for approval
- Department can see pending approvals
- Department can approve/reject requests
After Phase 5
- Admin can manage departments
- Admin can manage workflows
- Admin can view audit logs
- Webhooks can be configured and tested
After Phase 6
- Contracts deployed to Besu network
- Backend connects to contracts
- License minting works on approval
End-to-End Flow
- Applicant creates request -> uploads docs -> submits
- Department reviews -> approves
- License NFT minted on blockchain
- Applicant sees token ID and tx hash
- Document hashes verified on chain
---
Critical Files to Modify
Backend (update .env after contract deployment):
- backend/.env - Update CONTRACT_ADDRESS_* variables
Frontend (main configuration):
- frontend/src/app/app.config.ts - Add HTTP providers
- frontend/src/app/app.routes.ts - Define all routes
- frontend/src/styles.scss - Material theme
---
Implementation Order
Phase 1 (Core) -> Phase 2 (Auth) -> Phase 3 (Dashboard/Requests)
-> Phase 4 (Docs/Approvals) -> Phase 5 (Admin)
-> Phase 6 (Contracts) -> Phase 7 (Integration)

View File

@@ -1,3 +1,9 @@
# ==============================================================================
# Goa GEL Frontend - Multi-stage Docker Build
# ==============================================================================
# Supports runtime configuration via environment variables
# ==============================================================================
# Stage 1: Build Angular application # Stage 1: Build Angular application
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
@@ -21,9 +27,13 @@ FROM nginx:alpine
# Copy custom nginx configuration # Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
# Copy built application from builder stage (from browser subdirectory) # Copy built application from builder stage
COPY --from=builder /app/dist/goa-gel-frontend/browser /usr/share/nginx/html COPY --from=builder /app/dist/goa-gel-frontend/browser /usr/share/nginx/html
# Copy runtime config script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Expose port 80 # Expose port 80
EXPOSE 80 EXPOSE 80
@@ -31,5 +41,6 @@ EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Start nginx # Use entrypoint to inject runtime config
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,34 @@
#!/bin/sh
# ==============================================================================
# Goa GEL Frontend - Docker Entrypoint
# ==============================================================================
# Injects runtime configuration from environment variables
# ==============================================================================
set -e
# Configuration directory in nginx html root
CONFIG_DIR="/usr/share/nginx/html/assets"
CONFIG_FILE="${CONFIG_DIR}/config.json"
# Default values (same as build-time defaults)
API_BASE_URL="${API_BASE_URL:-http://localhost:3001/api/v1}"
echo "=== Goa GEL Frontend Runtime Configuration ==="
echo "API_BASE_URL: ${API_BASE_URL}"
echo "=============================================="
# Ensure config directory exists
mkdir -p "${CONFIG_DIR}"
# Generate runtime configuration JSON
cat > "${CONFIG_FILE}" << EOF
{
"apiBaseUrl": "${API_BASE_URL}"
}
EOF
echo "Runtime config written to ${CONFIG_FILE}"
# Execute the main command (nginx)
exec "$@"

3
frontend/e2e/CLAUDE.md Normal file
View File

@@ -0,0 +1,3 @@
<claude-mem-context>
</claude-mem-context>

View File

@@ -62,7 +62,7 @@ test.describe('Authentication', () => {
await page.goto('/auth/login'); await page.goto('/auth/login');
// Fill credentials // Fill credentials
await page.getByLabel('Email').fill('citizen@example.com'); await page.getByLabel('Email').fill('rajesh.naik@example.com');
await page.getByLabel('Password').fill('Citizen@123'); await page.getByLabel('Password').fill('Citizen@123');
// Submit // Submit

View File

@@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test';
// Helper functions // Helper functions
async function loginAsCitizen(page: Page) { async function loginAsCitizen(page: Page) {
await page.goto('/auth/login'); await page.goto('/auth/login');
await page.getByLabel('Email').fill('citizen@example.com'); await page.getByLabel('Email').fill('rajesh.naik@example.com');
await page.getByLabel('Password').fill('Citizen@123'); await page.getByLabel('Password').fill('Citizen@123');
await page.getByRole('button', { name: 'Sign In' }).click(); await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard**', { timeout: 10000 }); await page.waitForURL('**/dashboard**', { timeout: 10000 });

View File

@@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test';
// Helper function to login as citizen // Helper function to login as citizen
async function loginAsCitizen(page: Page) { async function loginAsCitizen(page: Page) {
await page.goto('/auth/login'); await page.goto('/auth/login');
await page.getByLabel('Email').fill('citizen@example.com'); await page.getByLabel('Email').fill('rajesh.naik@example.com');
await page.getByLabel('Password').fill('Citizen@123'); await page.getByLabel('Password').fill('Citizen@123');
await page.getByRole('button', { name: 'Sign In' }).click(); await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard**', { timeout: 10000 }); await page.waitForURL('**/dashboard**', { timeout: 10000 });

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