Compare commits
2 Commits
devops
...
d9de183e51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9de183e51 | ||
|
|
2c10cd5662 |
82
.env.example
82
.env.example
@@ -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_APPROVAL_MANAGER=
|
||||
CONTRACT_ADDRESS_DEPARTMENT_REGISTRY=
|
||||
CONTRACT_ADDRESS_WORKFLOW_REGISTRY=
|
||||
|
||||
# Platform Wallet Private Key
|
||||
# This will be generated during initial setup
|
||||
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
|
||||
# REDIS_PORT=6379
|
||||
# ==============================================================================
|
||||
# [OPTIONAL] Advanced Settings
|
||||
# ==============================================================================
|
||||
|
||||
# MinIO Configuration (optional overrides)
|
||||
# MINIO_ENDPOINT=minio
|
||||
# MINIO_PORT=9000
|
||||
# MINIO_ACCESS_KEY=minioadmin
|
||||
# MINIO_SECRET_KEY=minioadmin_secure
|
||||
# NODE_ENV=production
|
||||
# FORCE_RESEED=false
|
||||
|
||||
# JWT Secret (change in production)
|
||||
# JWT_SECRET=your-super-secure-jwt-secret-key-min-32-chars-long
|
||||
# External ports (if defaults conflict)
|
||||
# API_PORT=3001
|
||||
# FRONTEND_PORT=4200
|
||||
# BLOCKSCOUT_PORT=4000
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -39,3 +39,10 @@ session-backups/
|
||||
|
||||
# Archives
|
||||
*.zip
|
||||
|
||||
# Trash folder
|
||||
.trash/
|
||||
|
||||
# Test results
|
||||
test-results/
|
||||
frontend/test-results/
|
||||
|
||||
@@ -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! 🚀
|
||||
@@ -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/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/seeds ./src/database/seeds
|
||||
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
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
"migrate:rollback": "npm run knex -- migrate:rollback",
|
||||
"migrate:status": "npm run knex -- migrate:status",
|
||||
"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": {
|
||||
"@nestjs/bull": "10.0.1",
|
||||
|
||||
185
backend/scripts/db-reset.sh
Executable file
185
backend/scripts/db-reset.sh
Executable 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
184
backend/scripts/docker-entrypoint.sh
Normal file → Executable file
@@ -1,7 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting Goa-GEL Backend Initialization..."
|
||||
echo "========================================"
|
||||
echo " Goa-GEL Backend Initialization"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Function to check if this is first boot
|
||||
is_first_boot() {
|
||||
@@ -12,34 +15,191 @@ is_first_boot() {
|
||||
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
|
||||
mkdir -p /app/data
|
||||
|
||||
# Ensure .env file exists
|
||||
touch /app/.env
|
||||
|
||||
# 1. Wait for and initialize database
|
||||
echo "📊 Step 1: Database initialization..."
|
||||
chmod +x /app/scripts/init-db.sh
|
||||
/app/scripts/init-db.sh
|
||||
# ========================================
|
||||
# Step 1: Wait for PostgreSQL
|
||||
# ========================================
|
||||
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
|
||||
echo "🔗 Step 2: Blockchain initialization..."
|
||||
echo " - Blockchain not initialized, deploying contracts..."
|
||||
node /app/scripts/init-blockchain.js
|
||||
|
||||
# Mark as initialized
|
||||
touch /app/data/.initialized
|
||||
echo "✅ Blockchain initialization complete!"
|
||||
echo " - Blockchain initialization complete"
|
||||
|
||||
# Reload environment variables
|
||||
if [ -f "/app/.env" ]; then
|
||||
export $(grep -v '^#' /app/.env | xargs)
|
||||
set -a
|
||||
source /app/.env
|
||||
set +a
|
||||
fi
|
||||
else
|
||||
echo "⏭️ Step 2: Blockchain already initialized"
|
||||
echo " - Blockchain already initialized, skipping"
|
||||
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
|
||||
|
||||
20
backend/scripts/health-check.sh
Executable file
20
backend/scripts/health-check.sh
Executable 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
|
||||
@@ -140,6 +140,7 @@ async function deployPlaceholderContract(wallet, name) {
|
||||
|
||||
/**
|
||||
* Update .env file with generated values
|
||||
* Values containing spaces are quoted for shell compatibility
|
||||
*/
|
||||
function updateEnvFile(values) {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
@@ -151,11 +152,13 @@ function updateEnvFile(values) {
|
||||
|
||||
// Update or add each value
|
||||
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');
|
||||
if (regex.test(envContent)) {
|
||||
envContent = envContent.replace(regex, `${key}=${value}`);
|
||||
envContent = envContent.replace(regex, `${key}=${formattedValue}`);
|
||||
} else {
|
||||
envContent += `\n${key}=${value}`;
|
||||
envContent += `\n${key}=${formattedValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
backend/scripts/init-db.sh
Normal file → Executable file
32
backend/scripts/init-db.sh
Normal file → Executable file
@@ -1,30 +1,16 @@
|
||||
#!/bin/bash
|
||||
# This script is now deprecated - database initialization is handled by docker-entrypoint.sh
|
||||
# Kept for backward compatibility
|
||||
|
||||
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
|
||||
echo "⏳ PostgreSQL is unavailable - sleeping"
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "✅ PostgreSQL is up - checking if database is initialized..."
|
||||
|
||||
# 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!"
|
||||
echo "PostgreSQL is up and accepting connections"
|
||||
|
||||
214
backend/scripts/seed-demo-applications.sh
Executable file
214
backend/scripts/seed-demo-applications.sh
Executable 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!"
|
||||
@@ -39,7 +39,15 @@ import { UsersModule } from './modules/users/users.module';
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, blockchainConfig, storageConfig, redisConfig, jwtConfig, minioConfig],
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
blockchainConfig,
|
||||
storageConfig,
|
||||
redisConfig,
|
||||
jwtConfig,
|
||||
minioConfig,
|
||||
],
|
||||
validationSchema: appConfigValidationSchema,
|
||||
validationOptions: {
|
||||
abortEarly: false,
|
||||
@@ -57,10 +65,12 @@ import { UsersModule } from './modules/users/users.module';
|
||||
const nodeEnv = configService.get<string>('NODE_ENV', 'development');
|
||||
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),
|
||||
}];
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@ export const DEFAULT_PAGE_SIZE = 20;
|
||||
export const MAX_PAGE_SIZE = 100;
|
||||
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
export const ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/jpg',
|
||||
];
|
||||
export const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
|
||||
|
||||
export const REQUEST_NUMBER_PREFIX = {
|
||||
RESORT_LICENSE: 'RL',
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const CorrelationId = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
export const CorrelationId = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.headers['x-correlation-id'] || uuidv4();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ERROR_CODES } from '@common/constants/error-codes';
|
||||
|
||||
@@ -34,10 +28,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
let message = 'An unexpected error occurred';
|
||||
|
||||
if (exception instanceof Error) {
|
||||
this.logger.error(
|
||||
`Unhandled Exception: ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
this.logger.error(`Unhandled Exception: ${exception.message}`, exception.stack);
|
||||
|
||||
if (exception.message.includes('ECONNREFUSED')) {
|
||||
status = HttpStatus.SERVICE_UNAVAILABLE;
|
||||
|
||||
@@ -49,11 +49,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${message}`,
|
||||
exception.stack,
|
||||
correlationId,
|
||||
);
|
||||
this.logger.error(`Unhandled exception: ${message}`, exception.stack, correlationId);
|
||||
}
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { API_KEY_HEADER, DEPARTMENT_CODE_HEADER, ERROR_CODES } from '../constants';
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '../enums';
|
||||
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) {
|
||||
throw new ForbiddenException({
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Request, Response } from 'express';
|
||||
@@ -17,7 +11,7 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
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 startTime = Date.now();
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import { timeout } from 'rxjs/operators';
|
||||
@Injectable()
|
||||
export class TimeoutInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(
|
||||
timeout(30000),
|
||||
);
|
||||
return next.handle().pipe(timeout(30000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
PipeTransform,
|
||||
Injectable,
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { ERROR_CODES } from '../constants';
|
||||
@@ -19,7 +14,7 @@ export class CustomValidationPipe implements PipeTransform {
|
||||
const errors = await validate(object);
|
||||
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((error) => {
|
||||
const messages = errors.map(error => {
|
||||
const constraints = error.constraints || {};
|
||||
return {
|
||||
field: error.property,
|
||||
|
||||
@@ -15,10 +15,7 @@ export async function generateApiKey(): Promise<{
|
||||
const apiKey = `goa_${crypto.randomBytes(16).toString('hex')}`;
|
||||
const apiSecret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const [apiKeyHash, apiSecretHash] = await Promise.all([
|
||||
hash(apiKey),
|
||||
hash(apiSecret),
|
||||
]);
|
||||
const [apiKeyHash, apiSecretHash] = await Promise.all([hash(apiKey), hash(apiSecret)]);
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
@@ -40,10 +37,7 @@ export class CryptoUtil {
|
||||
const iv = randomBytes(CryptoUtil.IV_LENGTH);
|
||||
|
||||
const cipher = createCipheriv(CryptoUtil.ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(data, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
|
||||
@@ -85,10 +85,7 @@ export class DateUtil {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
return format
|
||||
.replace('DD', day)
|
||||
.replace('MM', month)
|
||||
.replace('YYYY', year.toString());
|
||||
return format.replace('DD', day).replace('MM', month).replace('YYYY', year.toString());
|
||||
}
|
||||
|
||||
static parseISO(dateString: string): Date {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryBuilder } from 'objection';
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
results: T[];
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function paginate<T>(
|
||||
const l = limit > 0 ? limit : 10;
|
||||
|
||||
const { results, total } = await query.page(p, l);
|
||||
return { results, total };
|
||||
return { data: results, total };
|
||||
}
|
||||
|
||||
export { QueryBuilder };
|
||||
@@ -16,7 +16,9 @@ export default registerAs('minio', (): MinioConfig => {
|
||||
const secretKey = process.env.MINIO_SECRET_KEY || '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 {
|
||||
|
||||
@@ -22,9 +22,7 @@ export const KNEX_CONNECTION = 'KNEX_CONNECTION';
|
||||
database: configService.get<string>('database.database'),
|
||||
user: configService.get<string>('database.username'),
|
||||
password: configService.get<string>('database.password'),
|
||||
ssl: configService.get<boolean>('database.ssl')
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
ssl: configService.get<boolean>('database.ssl') ? { rejectUnauthorized: false } : false,
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
|
||||
|
||||
// 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.string('digilocker_id', 255).notNullable().unique();
|
||||
table.string('name', 255).notNullable();
|
||||
@@ -21,7 +21,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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.string('code', 50).notNullable().unique();
|
||||
table.string('name', 255).notNullable();
|
||||
@@ -39,7 +39,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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.string('workflow_type', 100).notNullable().unique();
|
||||
table.string('name', 255).notNullable();
|
||||
@@ -56,11 +56,16 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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.string('request_number', 50).notNullable().unique();
|
||||
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.uuid('workflow_id').references('id').inTable('workflows').onDelete('SET NULL');
|
||||
table.string('status', 50).notNullable().defaultTo('DRAFT');
|
||||
@@ -81,9 +86,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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('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('original_filename', 255).notNullable();
|
||||
table.integer('current_version').notNullable().defaultTo(1);
|
||||
@@ -98,9 +108,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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('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.string('hash', 66).notNullable();
|
||||
table.string('minio_path', 500).notNullable();
|
||||
@@ -115,10 +130,20 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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('request_id').notNullable().references('id').inTable('license_requests').onDelete('CASCADE');
|
||||
table.uuid('department_id').notNullable().references('id').inTable('departments').onDelete('CASCADE');
|
||||
table
|
||||
.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.text('remarks');
|
||||
table.string('remarks_hash', 66);
|
||||
@@ -137,9 +162,15 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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('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.jsonb('completed_stages').notNullable().defaultTo('[]');
|
||||
table.jsonb('pending_approvals').notNullable().defaultTo('[]');
|
||||
@@ -152,9 +183,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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('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.jsonb('events').notNullable();
|
||||
table.string('secret_hash', 255).notNullable();
|
||||
@@ -166,7 +202,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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('webhook_id').notNullable().references('id').inTable('webhooks').onDelete('CASCADE');
|
||||
table.string('event_type', 100).notNullable();
|
||||
@@ -185,7 +221,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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.string('entity_type', 50).notNullable();
|
||||
table.uuid('entity_id').notNullable();
|
||||
@@ -207,7 +243,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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.string('tx_hash', 66).notNullable().unique();
|
||||
table.string('tx_type', 50).notNullable();
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 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.string('email', 255).notNullable().unique();
|
||||
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
|
||||
await knex.schema.createTable('wallets', (table) => {
|
||||
await knex.schema.createTable('wallets', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('address', 42).notNullable().unique();
|
||||
table.text('encrypted_private_key').notNullable();
|
||||
@@ -39,7 +39,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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.string('tx_hash', 66).notNullable();
|
||||
table.string('event_name', 100).notNullable();
|
||||
@@ -62,7 +62,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// 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.enum('level', ['DEBUG', 'INFO', 'WARN', 'ERROR']).notNullable();
|
||||
table.string('module', 100).notNullable();
|
||||
@@ -83,7 +83,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Add additional fields to departments table
|
||||
await knex.schema.alterTable('departments', (table) => {
|
||||
await knex.schema.alterTable('departments', table => {
|
||||
table.text('description');
|
||||
table.string('contact_email', 255);
|
||||
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> {
|
||||
// Remove additional fields from departments
|
||||
await knex.schema.alterTable('departments', (table) => {
|
||||
await knex.schema.alterTable('departments', table => {
|
||||
table.dropColumn('description');
|
||||
table.dropColumn('contact_email');
|
||||
table.dropColumn('contact_phone');
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Module, Global, Provider } from '@nestjs/common';
|
||||
import * as models from './models';
|
||||
|
||||
const modelProviders: Provider[] = Object.values(models)
|
||||
.filter((model: any) =>
|
||||
.filter(
|
||||
(model: any) =>
|
||||
typeof model === 'function' &&
|
||||
model.prototype &&
|
||||
(model.prototype instanceof models.BaseModel || model === models.BaseModel)
|
||||
(model.prototype instanceof models.BaseModel || model === models.BaseModel),
|
||||
)
|
||||
.map((model: any) => ({
|
||||
provide: model,
|
||||
|
||||
@@ -21,7 +21,15 @@ export class DocumentVersion extends BaseModel {
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['documentId', 'version', 'hash', 'minioPath', 'fileSize', 'mimeType', 'uploadedBy'],
|
||||
required: [
|
||||
'documentId',
|
||||
'version',
|
||||
'hash',
|
||||
'minioPath',
|
||||
'fileSize',
|
||||
'mimeType',
|
||||
'uploadedBy',
|
||||
],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
documentId: { type: 'string', format: 'uuid' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,11 @@ async function bootstrap(): Promise<void> {
|
||||
app.use(compression());
|
||||
|
||||
// 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({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
@@ -38,7 +42,13 @@ async function bootstrap(): Promise<void> {
|
||||
}
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -57,10 +67,11 @@ async function bootstrap(): Promise<void> {
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
exceptionFactory: (errors) => {
|
||||
exceptionFactory: errors => {
|
||||
// Return first error message as string for better test compatibility
|
||||
const firstError = errors[0];
|
||||
const firstConstraint = firstError && firstError.constraints
|
||||
const firstConstraint =
|
||||
firstError && firstError.constraints
|
||||
? Object.values(firstError.constraints)[0]
|
||||
: 'Validation failed';
|
||||
return new (require('@nestjs/common').BadRequestException)(firstConstraint);
|
||||
@@ -72,10 +83,7 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
// Global interceptors
|
||||
app.useGlobalInterceptors(
|
||||
new CorrelationIdInterceptor(),
|
||||
new LoggingInterceptor(),
|
||||
);
|
||||
app.useGlobalInterceptors(new CorrelationIdInterceptor(), new LoggingInterceptor());
|
||||
|
||||
// Swagger documentation
|
||||
if (swaggerEnabled) {
|
||||
@@ -142,7 +150,7 @@ async function bootstrap(): Promise<void> {
|
||||
logger.log(`API endpoint: http://localhost:${port}/${apiPrefix}/${apiVersion}`);
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
bootstrap().catch(error => {
|
||||
const logger = new Logger('Bootstrap');
|
||||
logger.error('Failed to start application', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -36,7 +36,8 @@ export class AdminController {
|
||||
@Get('stats')
|
||||
@ApiOperation({
|
||||
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: 401, description: 'Unauthorized' })
|
||||
@@ -49,7 +50,8 @@ export class AdminController {
|
||||
@Get('health')
|
||||
@ApiOperation({
|
||||
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' })
|
||||
async getHealth() {
|
||||
@@ -72,25 +74,51 @@ export class AdminController {
|
||||
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')
|
||||
@ApiOperation({
|
||||
summary: 'List blockchain transactions',
|
||||
description: 'Get paginated list of blockchain transactions with optional status filter',
|
||||
})
|
||||
@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)' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by transaction status (PENDING, CONFIRMED, FAILED)' })
|
||||
@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)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'status',
|
||||
required: false,
|
||||
description: 'Filter by transaction status (PENDING, CONFIRMED, FAILED)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Paginated blockchain transactions' })
|
||||
async getBlockchainTransactions(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.adminService.getBlockchainTransactions(
|
||||
page || 1,
|
||||
limit || 20,
|
||||
status,
|
||||
);
|
||||
return this.adminService.getBlockchainTransactions(page || 1, limit || 20, status);
|
||||
}
|
||||
|
||||
@Post('departments')
|
||||
@@ -123,13 +151,20 @@ export class AdminController {
|
||||
summary: 'List all departments',
|
||||
description: 'Get list of all departments 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)' })
|
||||
@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: 'List of departments' })
|
||||
async getDepartments(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
async getDepartments(@Query('page') page?: number, @Query('limit') limit?: number) {
|
||||
return this.adminService.getDepartments(page || 1, limit || 20);
|
||||
}
|
||||
|
||||
@@ -203,8 +238,18 @@ export class AdminController {
|
||||
summary: 'List blockchain events',
|
||||
description: 'Get paginated list of blockchain events with optional filters',
|
||||
})
|
||||
@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)' })
|
||||
@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)',
|
||||
})
|
||||
@ApiQuery({ name: 'eventType', required: false, description: 'Filter by event type' })
|
||||
@ApiQuery({ name: 'contractAddress', required: false, description: 'Filter by contract address' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated blockchain events' })
|
||||
@@ -227,9 +272,23 @@ export class AdminController {
|
||||
summary: 'List application logs',
|
||||
description: 'Get paginated list of application logs with optional filters',
|
||||
})
|
||||
@ApiQuery({ name: 'page', 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: 'page',
|
||||
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: 'search', required: false, description: 'Search in log messages' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated application logs' })
|
||||
@@ -240,13 +299,7 @@ export class AdminController {
|
||||
@Query('module') module?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.adminService.getApplicationLogs(
|
||||
page || 1,
|
||||
limit || 50,
|
||||
level,
|
||||
module,
|
||||
search,
|
||||
);
|
||||
return this.adminService.getApplicationLogs(page || 1, limit || 50, level, module, search);
|
||||
}
|
||||
|
||||
@Get('documents/:requestId')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ethers } from 'ethers';
|
||||
import { LicenseRequest } from '../../database/models/license-request.model';
|
||||
import { Applicant } from '../../database/models/applicant.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 { UsersService } from '../users/users.service';
|
||||
|
||||
export interface StatusCount {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PlatformStats {
|
||||
totalRequests: number;
|
||||
requestsByStatus: Record<string, number>;
|
||||
totalApprovals: number;
|
||||
requestsByStatus: StatusCount[];
|
||||
totalApplicants: number;
|
||||
activeApplicants: number;
|
||||
totalDepartments: number;
|
||||
activeDepartments: number;
|
||||
totalDocuments: number;
|
||||
totalBlockchainTransactions: number;
|
||||
transactionsByStatus: Record<string, number>;
|
||||
transactionsByStatus: StatusCount[];
|
||||
averageProcessingTime: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
@@ -70,44 +79,43 @@ export class AdminService {
|
||||
transactionsByStatus,
|
||||
] = await Promise.all([
|
||||
this.requestModel.query().resultSize(),
|
||||
this.requestModel
|
||||
.query()
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status') as any,
|
||||
this.requestModel.query().select('status').count('* as count').groupBy('status') as any,
|
||||
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().where({ isActive: true }).resultSize(),
|
||||
this.departmentModel.query().where({ is_active: true }).resultSize(),
|
||||
this.documentModel.query().resultSize(),
|
||||
this.blockchainTxModel.query().resultSize(),
|
||||
this.blockchainTxModel
|
||||
.query()
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status') as any,
|
||||
this.blockchainTxModel.query().select('status').count('* as count').groupBy('status') as any,
|
||||
]);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
for (const row of requestsByStatus) {
|
||||
statusMap[(row as any).status] = parseInt((row as any).count, 10);
|
||||
}
|
||||
// Convert to array format expected by frontend
|
||||
const statusArray: StatusCount[] = requestsByStatus.map((row: any) => ({
|
||||
status: row.status,
|
||||
count: parseInt(row.count, 10),
|
||||
}));
|
||||
|
||||
const txStatusMap: Record<string, number> = {};
|
||||
for (const row of transactionsByStatus) {
|
||||
txStatusMap[(row as any).status] = parseInt((row as any).count, 10);
|
||||
}
|
||||
const txStatusArray: StatusCount[] = transactionsByStatus.map((row: any) => ({
|
||||
status: row.status,
|
||||
count: parseInt(row.count, 10),
|
||||
}));
|
||||
|
||||
// Calculate total approvals
|
||||
const approvedCount = statusArray.find(s => s.status === 'APPROVED')?.count || 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsByStatus: statusMap,
|
||||
totalApprovals: approvedCount,
|
||||
requestsByStatus: statusArray,
|
||||
totalApplicants,
|
||||
activeApplicants,
|
||||
totalDepartments,
|
||||
activeDepartments,
|
||||
totalDocuments,
|
||||
totalBlockchainTransactions,
|
||||
transactionsByStatus: txStatusMap,
|
||||
transactionsByStatus: txStatusArray,
|
||||
averageProcessingTime: 4.5, // Placeholder
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,8 +129,7 @@ export class AdminService {
|
||||
dbStatus = 'down';
|
||||
}
|
||||
|
||||
const overallStatus =
|
||||
dbStatus === 'up' ? 'healthy' : 'unhealthy';
|
||||
const overallStatus = dbStatus === 'up' ? 'healthy' : 'unhealthy';
|
||||
|
||||
return {
|
||||
status: overallStatus,
|
||||
@@ -138,17 +145,10 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async getRecentActivity(limit: number = 20): Promise<AuditLog[]> {
|
||||
return this.auditLogModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC')
|
||||
.limit(limit);
|
||||
return this.auditLogModel.query().orderBy('created_at', 'DESC').limit(limit);
|
||||
}
|
||||
|
||||
async getBlockchainTransactions(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: string,
|
||||
) {
|
||||
async getBlockchainTransactions(page: number = 1, limit: number = 20, status?: string) {
|
||||
const query = this.blockchainTxModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (status) {
|
||||
@@ -177,7 +177,8 @@ export class AdminService {
|
||||
department: result.department,
|
||||
apiKey: result.apiKey,
|
||||
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);
|
||||
return {
|
||||
...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,
|
||||
contractAddress?: string,
|
||||
) {
|
||||
const query = this.blockchainEventModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
const query = this.blockchainEventModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (eventType) {
|
||||
query.where({ eventType });
|
||||
@@ -255,9 +255,7 @@ export class AdminService {
|
||||
module?: string,
|
||||
search?: string,
|
||||
) {
|
||||
const query = this.appLogModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
const query = this.appLogModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (level) {
|
||||
query.where({ level });
|
||||
@@ -293,7 +291,9 @@ export class AdminService {
|
||||
const documents = await this.documentModel
|
||||
.query()
|
||||
.where({ requestId })
|
||||
.withGraphFetched('[uploadedByUser, versions.[uploadedByUser], departmentReviews.[department]]')
|
||||
.withGraphFetched(
|
||||
'[uploadedByUser, versions.[uploadedByUser], departmentReviews.[department]]',
|
||||
)
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
// Transform documents to include formatted data
|
||||
@@ -309,7 +309,8 @@ export class AdminService {
|
||||
uploadedAt: doc.createdAt,
|
||||
uploadedBy: doc.uploadedByUser?.name || 'Unknown',
|
||||
currentVersion: doc.version || 1,
|
||||
versions: doc.versions?.map((v: any) => ({
|
||||
versions:
|
||||
doc.versions?.map((v: any) => ({
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
fileHash: v.fileHash,
|
||||
@@ -317,7 +318,8 @@ export class AdminService {
|
||||
uploadedBy: v.uploadedByUser?.name || 'Unknown',
|
||||
changes: v.changes,
|
||||
})) || [],
|
||||
departmentReviews: doc.departmentReviews?.map((review: any) => ({
|
||||
departmentReviews:
|
||||
doc.departmentReviews?.map((review: any) => ({
|
||||
departmentCode: review.department?.code || 'UNKNOWN',
|
||||
departmentName: review.department?.name || 'Unknown Department',
|
||||
reviewedAt: review.createdAt,
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApplicantsService } from './applicants.service';
|
||||
import { CreateApplicantDto, UpdateApplicantDto, ApplicantResponseDto } from './dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -36,10 +30,7 @@ export class ApplicantsController {
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, description: 'List of applicants' })
|
||||
async findAll(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
async findAll(@Query('page') page?: number, @Query('limit') limit?: number) {
|
||||
return this.applicantsService.findAll({ page, limit });
|
||||
}
|
||||
|
||||
@@ -53,6 +44,28 @@ export class ApplicantsController {
|
||||
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()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiBearerAuth('BearerAuth')
|
||||
@@ -69,10 +82,7 @@ export class ApplicantsController {
|
||||
@ApiOperation({ summary: 'Update applicant' })
|
||||
@ApiResponse({ status: 200, description: 'Applicant updated', type: ApplicantResponseDto })
|
||||
@ApiResponse({ status: 404, description: 'Applicant not found' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateApplicantDto,
|
||||
) {
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApplicantDto) {
|
||||
return this.applicantsService.update(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
|
||||
import { Applicant } from '../../database/models';
|
||||
import { Injectable, NotFoundException, ConflictException, Logger, Inject } from '@nestjs/common';
|
||||
import { Applicant, LicenseRequest, Document } from '../../database/models';
|
||||
import { CreateApplicantDto, UpdateApplicantDto } from './dto';
|
||||
import { ERROR_CODES } from '../../common/constants';
|
||||
import { paginate, PaginationOptions, PaginatedResult } from '../../common/utils/pagination.util';
|
||||
|
||||
export interface ApplicantStats {
|
||||
totalRequests: number;
|
||||
pendingRequests: number;
|
||||
approvedLicenses: number;
|
||||
documentsUploaded: number;
|
||||
blockchainRecords: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApplicantsService {
|
||||
private readonly logger = new Logger(ApplicantsService.name);
|
||||
@@ -31,9 +39,7 @@ export class ApplicantsService {
|
||||
}
|
||||
|
||||
async findAll(options: PaginationOptions): Promise<PaginatedResult<Applicant>> {
|
||||
const query = Applicant.query()
|
||||
.where('is_active', true)
|
||||
.orderBy('created_at', 'desc');
|
||||
const query = Applicant.query().where('is_active', true).orderBy('created_at', 'desc');
|
||||
|
||||
return await paginate(query, options.page, options.limit);
|
||||
}
|
||||
@@ -94,4 +100,48 @@ export class ApplicantsService {
|
||||
await Applicant.query().patchAndFetchById(id, { isActive: false });
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,58 @@ export class ApprovalsController {
|
||||
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')
|
||||
@ApiOperation({
|
||||
summary: 'Get approval by ID',
|
||||
@@ -323,10 +375,7 @@ export class ApprovalsController {
|
||||
@CorrelationId() correlationId?: string,
|
||||
): Promise<ApprovalResponseDto[]> {
|
||||
this.logger.debug(`[${correlationId}] Fetching approvals for request: ${requestId}`);
|
||||
return this.approvalsService.findByRequestId(
|
||||
requestId,
|
||||
includeInvalidated === 'true',
|
||||
);
|
||||
return this.approvalsService.findByRequestId(requestId, includeInvalidated === 'true');
|
||||
}
|
||||
|
||||
@Get('department/:departmentCode')
|
||||
|
||||
@@ -70,21 +70,24 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
// 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('department_id', departmentId)
|
||||
.whereNull('invalidated_at')
|
||||
.first();
|
||||
|
||||
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');
|
||||
}
|
||||
if (existingApproval.status === ApprovalStatus.REJECTED as any) {
|
||||
if (existingApproval.status === (ApprovalStatus.REJECTED as any)) {
|
||||
throw new BadRequestException('Request already rejected by your department');
|
||||
}
|
||||
if (existingApproval.status !== ApprovalStatus.PENDING as any) {
|
||||
throw new BadRequestException(`Cannot approve request with status ${existingApproval.status}`);
|
||||
if (existingApproval.status !== (ApprovalStatus.PENDING as any)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot approve request with status ${existingApproval.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +96,8 @@ export class ApprovalsService {
|
||||
const deptCode = department?.code;
|
||||
|
||||
// Check workflow step authorization
|
||||
const workflowRequest = await this.requestsRepository.query()
|
||||
const workflowRequest = await this.requestsRepository
|
||||
.query()
|
||||
.findById(requestId)
|
||||
.withGraphFetched('workflow');
|
||||
|
||||
@@ -116,19 +120,19 @@ export class ApprovalsService {
|
||||
// Check if department is in current stage
|
||||
if (currentStageIndex < definition.stages.length) {
|
||||
const currentStage = definition.stages[currentStageIndex];
|
||||
const isInCurrentStage = currentStage.requiredApprovals?.some((ra: any) =>
|
||||
ra.departmentCode === deptCode
|
||||
const isInCurrentStage = currentStage.requiredApprovals?.some(
|
||||
(ra: any) => ra.departmentCode === deptCode,
|
||||
);
|
||||
|
||||
if (!isInCurrentStage) {
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to the current workflow step'
|
||||
'Your department is not assigned to the current workflow step',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// All stages complete - department not in any active stage
|
||||
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
|
||||
const approval = await this.findPendingApproval(requestId, departmentId);
|
||||
if (!approval) {
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to approve this request',
|
||||
);
|
||||
throw new ForbiddenException('Your department is not assigned to approve this request');
|
||||
}
|
||||
|
||||
// Use comments if remarks is not provided
|
||||
@@ -156,9 +158,8 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
// Generate blockchain transaction hash for the approval
|
||||
const blockchainTxHash = '0x' + Array.from({ length: 64 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('');
|
||||
const blockchainTxHash =
|
||||
'0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
|
||||
const saved = await approval.$query().patchAndFetch({
|
||||
status: (dto.status || ApprovalStatus.APPROVED) as any,
|
||||
@@ -169,7 +170,8 @@ export class ApprovalsService {
|
||||
});
|
||||
|
||||
// Fetch with department relation
|
||||
const result = await this.approvalsRepository.query()
|
||||
const result = await this.approvalsRepository
|
||||
.query()
|
||||
.findById(saved.id)
|
||||
.withGraphFetched('department');
|
||||
|
||||
@@ -200,8 +202,7 @@ export class ApprovalsService {
|
||||
|
||||
// If no next stage, mark request as approved
|
||||
if (!nextStageCreated) {
|
||||
await this.requestsRepository.query()
|
||||
.patchAndFetchById(requestId, {
|
||||
await this.requestsRepository.query().patchAndFetchById(requestId, {
|
||||
status: RequestStatus.APPROVED,
|
||||
});
|
||||
}
|
||||
@@ -217,7 +218,8 @@ export class ApprovalsService {
|
||||
responseDto.workflowComplete = workflowComplete;
|
||||
|
||||
// Calculate current step index
|
||||
const workflowRequestForStep = await this.requestsRepository.query()
|
||||
const workflowRequestForStep = await this.requestsRepository
|
||||
.query()
|
||||
.findById(requestId)
|
||||
.withGraphFetched('workflow');
|
||||
|
||||
@@ -285,17 +287,18 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
// 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('department_id', departmentId)
|
||||
.whereNull('invalidated_at')
|
||||
.first();
|
||||
|
||||
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');
|
||||
}
|
||||
if (existingApproval.status === ApprovalStatus.REJECTED as any) {
|
||||
if (existingApproval.status === (ApprovalStatus.REJECTED as any)) {
|
||||
throw new BadRequestException('Request already rejected by your department');
|
||||
}
|
||||
}
|
||||
@@ -305,7 +308,8 @@ export class ApprovalsService {
|
||||
const deptCode = department?.code;
|
||||
|
||||
// Check workflow step authorization
|
||||
const workflowRequest = await this.requestsRepository.query()
|
||||
const workflowRequest = await this.requestsRepository
|
||||
.query()
|
||||
.findById(requestId)
|
||||
.withGraphFetched('workflow');
|
||||
|
||||
@@ -328,19 +332,19 @@ export class ApprovalsService {
|
||||
// Check if department is in current stage
|
||||
if (currentStageIndex < definition.stages.length) {
|
||||
const currentStage = definition.stages[currentStageIndex];
|
||||
const isInCurrentStage = currentStage.requiredApprovals?.some((ra: any) =>
|
||||
ra.departmentCode === deptCode
|
||||
const isInCurrentStage = currentStage.requiredApprovals?.some(
|
||||
(ra: any) => ra.departmentCode === deptCode,
|
||||
);
|
||||
|
||||
if (!isInCurrentStage) {
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to the current workflow step'
|
||||
'Your department is not assigned to the current workflow step',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// All stages complete - department not in any active stage
|
||||
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
|
||||
const approval = await this.findPendingApproval(requestId, departmentId);
|
||||
if (!approval) {
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to this request',
|
||||
);
|
||||
throw new ForbiddenException('Your department is not assigned to this request');
|
||||
}
|
||||
|
||||
// Use comments if remarks is not provided
|
||||
@@ -364,13 +366,14 @@ export class ApprovalsService {
|
||||
|
||||
// Validate minimum length
|
||||
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
|
||||
const blockchainTxHash = '0x' + Array.from({ length: 64 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('');
|
||||
const blockchainTxHash =
|
||||
'0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
|
||||
const saved = await approval.$query().patchAndFetch({
|
||||
status: ApprovalStatus.REJECTED as any,
|
||||
@@ -380,7 +383,8 @@ export class ApprovalsService {
|
||||
});
|
||||
|
||||
// Fetch with department relation for audit log
|
||||
const savedWithDept = await this.approvalsRepository.query()
|
||||
const savedWithDept = await this.approvalsRepository
|
||||
.query()
|
||||
.findById(saved.id)
|
||||
.withGraphFetched('department');
|
||||
const departmentCode = (savedWithDept as any).department?.code || departmentId;
|
||||
@@ -392,7 +396,13 @@ export class ApprovalsService {
|
||||
action: 'REQUEST_REJECTED',
|
||||
actorType: 'DEPARTMENT',
|
||||
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)
|
||||
@@ -404,8 +414,7 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
// Update request status to REJECTED
|
||||
await this.requestsRepository.query()
|
||||
.patchAndFetchById(requestId, {
|
||||
await this.requestsRepository.query().patchAndFetchById(requestId, {
|
||||
status: RequestStatus.REJECTED,
|
||||
});
|
||||
|
||||
@@ -424,9 +433,7 @@ export class ApprovalsService {
|
||||
const approval = await this.findPendingApproval(requestId, departmentId);
|
||||
|
||||
if (!approval) {
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to this request',
|
||||
);
|
||||
throw new ForbiddenException('Your department is not assigned to this request');
|
||||
}
|
||||
|
||||
const saved = await approval.$query().patchAndFetch({
|
||||
@@ -470,7 +477,38 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
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 limit = query.limit || 10;
|
||||
|
||||
const { results: approvals, total } = await this.approvalsRepository.query()
|
||||
const { results: approvals, total } = await this.approvalsRepository
|
||||
.query()
|
||||
.where('department_id', departmentId)
|
||||
.whereNull('invalidated_at')
|
||||
.orderBy('created_at', 'DESC')
|
||||
.page(page, limit);
|
||||
|
||||
return {
|
||||
data: approvals.map((a) => this.mapToResponseDto(a)),
|
||||
data: approvals.map(a => this.mapToResponseDto(a)),
|
||||
meta: {
|
||||
total,
|
||||
page: query.page,
|
||||
@@ -531,10 +570,7 @@ export class ApprovalsService {
|
||||
const affectedDepartments: string[] = [];
|
||||
|
||||
for (const approval of approvals) {
|
||||
if (
|
||||
approval.reviewedDocuments &&
|
||||
(approval.reviewedDocuments as any).includes(documentId)
|
||||
) {
|
||||
if (approval.reviewedDocuments && (approval.reviewedDocuments as any).includes(documentId)) {
|
||||
if (approval.status === (ApprovalStatus.APPROVED as any)) {
|
||||
await approval.$query().patch({
|
||||
invalidatedAt: new Date(),
|
||||
@@ -551,10 +587,7 @@ export class ApprovalsService {
|
||||
/**
|
||||
* Revalidate an invalidated approval
|
||||
*/
|
||||
async revalidateApproval(
|
||||
approvalId: string,
|
||||
dto: RevalidateDto,
|
||||
): Promise<ApprovalResponseDto> {
|
||||
async revalidateApproval(approvalId: string, dto: RevalidateDto): Promise<ApprovalResponseDto> {
|
||||
const approval = await this.approvalsRepository.query().findById(approvalId);
|
||||
|
||||
if (!approval) {
|
||||
@@ -562,9 +595,7 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
if (!approval.invalidatedAt) {
|
||||
throw new BadRequestException(
|
||||
`Approval ${approvalId} is not in an invalidated state`,
|
||||
);
|
||||
throw new BadRequestException(`Approval ${approvalId} is not in an invalidated state`);
|
||||
}
|
||||
|
||||
const saved = await approval.$query().patchAndFetch({
|
||||
@@ -580,11 +611,9 @@ export class ApprovalsService {
|
||||
/**
|
||||
* Check if a department can approve at this stage
|
||||
*/
|
||||
async canDepartmentApprove(
|
||||
requestId: string,
|
||||
departmentId: string,
|
||||
): Promise<boolean> {
|
||||
const approval = await this.approvalsRepository.query()
|
||||
async canDepartmentApprove(requestId: string, departmentId: string): Promise<boolean> {
|
||||
const approval = await this.approvalsRepository
|
||||
.query()
|
||||
.where('request_id', requestId)
|
||||
.where('department_id', departmentId)
|
||||
.where('status', ApprovalStatus.PENDING as any)
|
||||
@@ -598,30 +627,33 @@ export class ApprovalsService {
|
||||
* Get all pending approvals for a request
|
||||
*/
|
||||
async getPendingApprovals(requestId: string): Promise<ApprovalResponseDto[]> {
|
||||
const approvals = await this.approvalsRepository.query()
|
||||
const approvals = await this.approvalsRepository
|
||||
.query()
|
||||
.where('request_id', requestId)
|
||||
.where('status', ApprovalStatus.PENDING as any)
|
||||
.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
|
||||
*/
|
||||
async getActiveApprovals(requestId: string): Promise<ApprovalResponseDto[]> {
|
||||
const approvals = await this.approvalsRepository.query()
|
||||
const approvals = await this.approvalsRepository
|
||||
.query()
|
||||
.where('request_id', requestId)
|
||||
.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
|
||||
*/
|
||||
async areAllApprovalsComplete(requestId: string): Promise<boolean> {
|
||||
const pendingCount = await this.approvalsRepository.query()
|
||||
const pendingCount = await this.approvalsRepository
|
||||
.query()
|
||||
.where('request_id', requestId)
|
||||
.where('status', ApprovalStatus.PENDING as any)
|
||||
.whereNull('invalidated_at')
|
||||
@@ -633,14 +665,13 @@ export class ApprovalsService {
|
||||
/**
|
||||
* Get count of approvals by status
|
||||
*/
|
||||
async getApprovalCountByStatus(
|
||||
requestId: string,
|
||||
): Promise<Record<ApprovalStatus, number>> {
|
||||
async getApprovalCountByStatus(requestId: string): Promise<Record<ApprovalStatus, number>> {
|
||||
const statuses = Object.values(ApprovalStatus);
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const status of statuses) {
|
||||
const count = await this.approvalsRepository.query()
|
||||
const count = await this.approvalsRepository
|
||||
.query()
|
||||
.where('request_id', requestId)
|
||||
.where('status', status as any)
|
||||
.whereNull('invalidated_at')
|
||||
@@ -673,7 +704,8 @@ export class ApprovalsService {
|
||||
requestId: string,
|
||||
departmentId: string,
|
||||
): Promise<Approval | null> {
|
||||
return this.approvalsRepository.query()
|
||||
return this.approvalsRepository
|
||||
.query()
|
||||
.where('request_id', requestId)
|
||||
.where('department_id', departmentId)
|
||||
.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
|
||||
*/
|
||||
private async createNextStageApprovals(requestId: string): Promise<boolean> {
|
||||
const request = await this.requestsRepository.query()
|
||||
const request = await this.requestsRepository
|
||||
.query()
|
||||
.findById(requestId)
|
||||
.withGraphFetched('workflow');
|
||||
|
||||
@@ -702,7 +735,8 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
// 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)
|
||||
.whereNull('invalidated_at')
|
||||
.withGraphFetched('department');
|
||||
@@ -740,12 +774,14 @@ export class ApprovalsService {
|
||||
const nextStage = stages[currentStageIndex];
|
||||
|
||||
for (const deptApproval of nextStage.requiredApprovals || []) {
|
||||
const department = await this.departmentRepository.query()
|
||||
const department = await this.departmentRepository
|
||||
.query()
|
||||
.findOne({ code: deptApproval.departmentCode });
|
||||
|
||||
if (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('department_id', department.id)
|
||||
.whereNull('invalidated_at')
|
||||
@@ -777,7 +813,8 @@ export class ApprovalsService {
|
||||
|
||||
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)
|
||||
.whereNull('invalidated_at')
|
||||
.withGraphFetched('department');
|
||||
@@ -809,8 +846,10 @@ export class ApprovalsService {
|
||||
status: approval.status as any,
|
||||
approvedBy: (approval as any).approvedBy,
|
||||
rejectedBy: (approval as any).approvedBy, // Alias for approvedBy
|
||||
approvedAt: approval.status === (ApprovalStatus.APPROVED as any) ? approval.updatedAt : undefined,
|
||||
rejectedAt: approval.status === (ApprovalStatus.REJECTED as any) ? approval.updatedAt : undefined,
|
||||
approvedAt:
|
||||
approval.status === (ApprovalStatus.APPROVED as any) ? approval.updatedAt : undefined,
|
||||
rejectedAt:
|
||||
approval.status === (ApprovalStatus.REJECTED as any) ? approval.updatedAt : undefined,
|
||||
remarks: approval.remarks,
|
||||
comments: approval.remarks, // Alias for remarks
|
||||
reviewedDocuments: approval.reviewedDocuments as any,
|
||||
|
||||
@@ -7,7 +7,8 @@ export class RejectRequestDto {
|
||||
description: 'Detailed remarks explaining the rejection',
|
||||
minLength: 5,
|
||||
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()
|
||||
@IsString()
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Param,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Query, Param, UseGuards, Logger } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -30,20 +23,43 @@ export class AuditController {
|
||||
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
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: '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: '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: 'startDate', required: false, description: 'Filter from 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({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
|
||||
@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 audit logs' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
|
||||
@@ -68,13 +84,13 @@ export class AuditController {
|
||||
summary: 'Get audit trail for entity',
|
||||
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)' })
|
||||
@ApiResponse({ status: 200, description: 'Audit trail for entity' })
|
||||
async findByEntity(
|
||||
@Param('entityType') entityType: string,
|
||||
@Param('entityId') entityId: string,
|
||||
) {
|
||||
async findByEntity(@Param('entityType') entityType: string, @Param('entityId') entityId: string) {
|
||||
return this.auditService.findByEntity(entityType, entityId);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,7 @@ export class AuditService {
|
||||
) {}
|
||||
|
||||
async record(dto: CreateAuditLogDto): Promise<AuditLog> {
|
||||
this.logger.debug(
|
||||
`Recording audit: ${dto.action} on ${dto.entityType}/${dto.entityId}`,
|
||||
);
|
||||
this.logger.debug(`Recording audit: ${dto.action} on ${dto.entityType}/${dto.entityId}`);
|
||||
|
||||
return this.auditLogModel.query().insert({
|
||||
entityType: dto.entityType,
|
||||
@@ -106,15 +104,21 @@ export class AuditService {
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
// Transform to add performedBy and details fields from newValue
|
||||
return logs.map((log) => ({
|
||||
return logs.map(log => ({
|
||||
...log,
|
||||
performedBy: (log.newValue as any)?.performedBy,
|
||||
details: (log.newValue as any)?.reason || (log.newValue as any)?.remarks ||
|
||||
details:
|
||||
(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 {
|
||||
actions: Object.values(AuditAction),
|
||||
entityTypes: Object.values(EntityType),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
EmailPasswordLoginDto,
|
||||
LoginResponseDto,
|
||||
DigiLockerLoginResponseDto,
|
||||
UserLoginResponseDto
|
||||
UserLoginResponseDto,
|
||||
} from './dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
|
||||
@@ -139,9 +139,7 @@ export class AuthService {
|
||||
/**
|
||||
* Email/Password login for all user types (Admin, Department, Citizen)
|
||||
*/
|
||||
async emailPasswordLogin(
|
||||
dto: EmailPasswordLoginDto,
|
||||
): Promise<{
|
||||
async emailPasswordLogin(dto: EmailPasswordLoginDto): Promise<{
|
||||
accessToken: string;
|
||||
user: {
|
||||
id: string;
|
||||
|
||||
@@ -18,6 +18,6 @@ export class RolesGuard implements CanActivate {
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user?.role === role);
|
||||
return requiredRoles.some(role => user?.role === role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ export class ApprovalChainService {
|
||||
try {
|
||||
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
|
||||
const statusMap = {
|
||||
@@ -66,7 +69,10 @@ export class ApprovalChainService {
|
||||
requestId: string,
|
||||
): Promise<OnChainApproval[]> {
|
||||
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);
|
||||
|
||||
@@ -89,14 +95,14 @@ export class ApprovalChainService {
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateApproval(
|
||||
contractAddress: string,
|
||||
approvalId: string,
|
||||
): Promise<string> {
|
||||
async invalidateApproval(contractAddress: string, approvalId: string): Promise<string> {
|
||||
try {
|
||||
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 receipt = await this.web3Service.sendTransaction(tx);
|
||||
@@ -115,7 +121,10 @@ export class ApprovalChainService {
|
||||
remarksHash: string,
|
||||
): Promise<boolean> {
|
||||
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);
|
||||
} catch (error) {
|
||||
@@ -124,12 +133,12 @@ export class ApprovalChainService {
|
||||
}
|
||||
}
|
||||
|
||||
async getApprovalDetails(
|
||||
contractAddress: string,
|
||||
approvalId: string,
|
||||
): Promise<OnChainApproval> {
|
||||
async getApprovalDetails(contractAddress: string, approvalId: string): Promise<OnChainApproval> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -77,9 +77,7 @@ export class BlockchainMonitorService {
|
||||
await this.sleep(this.POLL_INTERVAL);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
this.logger.warn(
|
||||
`Error polling transaction ${txHash}: ${lastError.message}`,
|
||||
);
|
||||
this.logger.warn(`Error polling transaction ${txHash}: ${lastError.message}`);
|
||||
attempts++;
|
||||
await this.sleep(this.POLL_INTERVAL);
|
||||
}
|
||||
@@ -178,7 +176,8 @@ export class BlockchainMonitorService {
|
||||
errorMessage?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.blockchainTxRepository.query()
|
||||
await this.blockchainTxRepository
|
||||
.query()
|
||||
.where({ txHash })
|
||||
.patch({
|
||||
status,
|
||||
@@ -188,14 +187,11 @@ export class BlockchainMonitorService {
|
||||
errorMessage: errorMessage || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to update transaction status in database: ${txHash}`,
|
||||
error,
|
||||
);
|
||||
this.logger.error(`Failed to update transaction status in database: ${txHash}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,7 @@ export class DocumentChainService {
|
||||
this.logger.log(`Document hash recorded: ${receipt.hash}`);
|
||||
return receipt.hash!;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to record document hash for ${documentId}`,
|
||||
error,
|
||||
);
|
||||
this.logger.error(`Failed to record document hash for ${documentId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -52,9 +49,7 @@ export class DocumentChainService {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any;
|
||||
|
||||
const isValid = await contract.verifyDocumentHash(documentId, hash);
|
||||
this.logger.debug(
|
||||
`Document hash verification: documentId=${documentId}, isValid=${isValid}`,
|
||||
);
|
||||
this.logger.debug(`Document hash verification: documentId=${documentId}, isValid=${isValid}`);
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to verify document hash for ${documentId}`, error);
|
||||
@@ -85,10 +80,7 @@ export class DocumentChainService {
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestDocumentHash(
|
||||
contractAddress: string,
|
||||
documentId: string,
|
||||
): Promise<string> {
|
||||
async getLatestDocumentHash(contractAddress: string, documentId: string): Promise<string> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any;
|
||||
|
||||
|
||||
@@ -74,10 +74,7 @@ export class LicenseNFTService {
|
||||
}
|
||||
}
|
||||
|
||||
async getLicenseMetadata(
|
||||
contractAddress: string,
|
||||
tokenId: bigint,
|
||||
): Promise<LicenseMetadata> {
|
||||
async getLicenseMetadata(contractAddress: string, tokenId: bigint): Promise<LicenseMetadata> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any;
|
||||
|
||||
@@ -135,10 +132,7 @@ export class LicenseNFTService {
|
||||
}
|
||||
}
|
||||
|
||||
async getTokenIdByRequest(
|
||||
contractAddress: string,
|
||||
requestId: string,
|
||||
): Promise<bigint | null> {
|
||||
async getTokenIdByRequest(contractAddress: string, requestId: string): Promise<bigint | null> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any;
|
||||
return await contract.tokenOfRequest(requestId);
|
||||
@@ -148,10 +142,7 @@ export class LicenseNFTService {
|
||||
}
|
||||
}
|
||||
|
||||
async verifyLicense(
|
||||
contractAddress: string,
|
||||
tokenId: bigint,
|
||||
): Promise<LicenseVerification> {
|
||||
async verifyLicense(contractAddress: string, tokenId: bigint): Promise<LicenseVerification> {
|
||||
try {
|
||||
const isValid = await this.isLicenseValid(contractAddress, tokenId);
|
||||
const metadata = await this.getLicenseMetadata(contractAddress, tokenId);
|
||||
|
||||
@@ -53,10 +53,7 @@ export class Web3Service implements OnModuleInit {
|
||||
return this.wallet;
|
||||
}
|
||||
|
||||
getContract<T extends ethers.BaseContract>(
|
||||
address: string,
|
||||
abi: ethers.InterfaceAbi,
|
||||
): T {
|
||||
getContract<T extends ethers.BaseContract>(address: string, abi: ethers.InterfaceAbi): T {
|
||||
if (!this.wallet) {
|
||||
throw new Error('Wallet not initialized');
|
||||
}
|
||||
@@ -116,14 +113,12 @@ export class Web3Service implements OnModuleInit {
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
this.logger.warn(
|
||||
`Transaction attempt ${attempt + 1} failed: ${lastError.message}`,
|
||||
);
|
||||
this.logger.warn(`Transaction attempt ${attempt + 1} failed: ${lastError.message}`);
|
||||
|
||||
// Check if it's a nonce issue
|
||||
if (lastError.message.includes('nonce') || lastError.message.includes('replacement')) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,10 @@ export class WalletService {
|
||||
/**
|
||||
* 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({
|
||||
ownerType,
|
||||
ownerId,
|
||||
|
||||
@@ -139,12 +139,10 @@ export class DepartmentsController {
|
||||
`[${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 {
|
||||
data: results.map((department) =>
|
||||
DepartmentResponseDto.fromEntity(department),
|
||||
),
|
||||
data: data.map(department => DepartmentResponseDto.fromEntity(department)),
|
||||
meta: {
|
||||
total,
|
||||
page: query.page,
|
||||
@@ -154,17 +152,17 @@ export class DepartmentsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':code')
|
||||
@Get(':identifier')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get department by code',
|
||||
description: 'Retrieve detailed information about a specific department',
|
||||
summary: 'Get department by ID or code',
|
||||
description: 'Retrieve detailed information about a specific department by UUID or code',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -179,27 +177,33 @@ export class DepartmentsController {
|
||||
status: 404,
|
||||
description: 'Department not found',
|
||||
})
|
||||
async findByCode(
|
||||
@Param('code') code: string,
|
||||
async findByIdentifier(
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<DepartmentResponseDto> {
|
||||
this.logger.debug(`[${correlationId}] Fetching department: ${code}`);
|
||||
this.logger.debug(`[${correlationId}] Fetching department: ${identifier}`);
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(
|
||||
`[${correlationId}] Error fetching department: ${error.message}`,
|
||||
);
|
||||
this.logger.error(`[${correlationId}] Error fetching department: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Patch(':code')
|
||||
@Patch(':identifier')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -208,9 +212,9 @@ export class DepartmentsController {
|
||||
description: 'Update department information (admin only)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -230,25 +234,26 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async update(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@Body() updateDepartmentDto: UpdateDepartmentDto,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<DepartmentResponseDto> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Updating department: ${code}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Updating department: ${identifier}`);
|
||||
|
||||
const department = await this.departmentsService.findByCode(code);
|
||||
const updated = await this.departmentsService.update(
|
||||
department.id,
|
||||
updateDepartmentDto,
|
||||
);
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@Post(':code/regenerate-api-key')
|
||||
@Post(':identifier/regenerate-api-key')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -259,9 +264,9 @@ export class DepartmentsController {
|
||||
'Generate a new API key pair for the department (admin only). Old key will be invalidated.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -282,33 +287,35 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async regenerateApiKey(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<{ apiKey: string; apiSecret: string }> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Regenerating API key for department: ${code}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Regenerating API key for department: ${identifier}`);
|
||||
|
||||
const department = await this.departmentsService.findByCode(code);
|
||||
const result = await this.departmentsService.regenerateApiKey(
|
||||
department.id,
|
||||
);
|
||||
const department = await this.findDepartmentByIdentifier(identifier);
|
||||
const result = await this.departmentsService.regenerateApiKey(department.id);
|
||||
|
||||
this.logger.log(
|
||||
`[${correlationId}] API key regenerated for department: ${code}`,
|
||||
);
|
||||
this.logger.log(`[${correlationId}] API key regenerated for department: ${identifier}`);
|
||||
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({
|
||||
summary: 'Get department statistics',
|
||||
description: 'Retrieve statistics for a specific department',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'startDate',
|
||||
@@ -332,14 +339,12 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async getStats(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@Query('startDate') startDateStr?: string,
|
||||
@Query('endDate') endDateStr?: string,
|
||||
@CorrelationId() correlationId?: string,
|
||||
): Promise<DepartmentStatsDto> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Fetching statistics for department: ${code}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Fetching statistics for department: ${identifier}`);
|
||||
|
||||
let startDate: Date | undefined;
|
||||
let endDate: Date | undefined;
|
||||
@@ -361,11 +366,12 @@ export class DepartmentsController {
|
||||
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;
|
||||
}
|
||||
|
||||
@Post(':code/activate')
|
||||
@Post(':identifier/activate')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -375,9 +381,9 @@ export class DepartmentsController {
|
||||
description: 'Activate a deactivated department (admin only)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -393,19 +399,19 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async activate(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: string,
|
||||
): 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);
|
||||
|
||||
this.logger.log(`[${correlationId}] Department activated: ${code}`);
|
||||
this.logger.log(`[${correlationId}] Department activated: ${identifier}`);
|
||||
return DepartmentResponseDto.fromEntity(activated);
|
||||
}
|
||||
|
||||
@Post(':code/deactivate')
|
||||
@Post(':identifier/deactivate')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -415,9 +421,9 @@ export class DepartmentsController {
|
||||
description: 'Deactivate a department (admin only)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -432,15 +438,15 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async deactivate(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: 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);
|
||||
|
||||
this.logger.log(`[${correlationId}] Department deactivated: ${code}`);
|
||||
return { message: `Department ${code} has been deactivated` };
|
||||
this.logger.log(`[${correlationId}] Department deactivated: ${identifier}`);
|
||||
return { message: `Department ${department.code} has been deactivated` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ export class DepartmentsService {
|
||||
});
|
||||
|
||||
if (existingDepartment) {
|
||||
throw new BadRequestException(
|
||||
`Department with code ${dto.code} already exists`,
|
||||
);
|
||||
throw new BadRequestException(`Department with code ${dto.code} already exists`);
|
||||
}
|
||||
|
||||
// Generate API key pair
|
||||
@@ -84,7 +82,9 @@ export class DepartmentsService {
|
||||
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 {
|
||||
department: updatedDepartment,
|
||||
@@ -99,12 +99,9 @@ export class DepartmentsService {
|
||||
* @returns Promise<PaginatedResult<Department>>
|
||||
*/
|
||||
async findAll(query: PaginationDto): Promise<PaginatedResult<Department>> {
|
||||
this.logger.debug(
|
||||
`Fetching departments - page: ${query.page}, limit: ${query.limit}`,
|
||||
);
|
||||
this.logger.debug(`Fetching departments - page: ${query.page}, limit: ${query.limit}`);
|
||||
|
||||
const queryBuilder = this.departmentRepository.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
const queryBuilder = this.departmentRepository.query().orderBy('created_at', 'DESC');
|
||||
|
||||
return await paginate(queryBuilder, query.page, query.limit);
|
||||
}
|
||||
@@ -139,9 +136,7 @@ export class DepartmentsService {
|
||||
});
|
||||
|
||||
if (!department) {
|
||||
throw new NotFoundException(
|
||||
`Department with code ${code} not found`,
|
||||
);
|
||||
throw new NotFoundException(`Department with code ${code} not found`);
|
||||
}
|
||||
|
||||
return department;
|
||||
@@ -165,9 +160,7 @@ export class DepartmentsService {
|
||||
});
|
||||
|
||||
if (existingDepartment) {
|
||||
throw new ConflictException(
|
||||
`Department with code ${dto.code} already exists`,
|
||||
);
|
||||
throw new ConflictException(`Department with code ${dto.code} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +197,7 @@ export class DepartmentsService {
|
||||
* @param webhookSecret - Webhook secret for HMAC verification
|
||||
* @returns Promise<Department>
|
||||
*/
|
||||
async updateWebhook(
|
||||
id: string,
|
||||
webhookUrl: string,
|
||||
webhookSecret?: string,
|
||||
): Promise<Department> {
|
||||
async updateWebhook(id: string, webhookUrl: string, webhookSecret?: string): Promise<Department> {
|
||||
this.logger.debug(`Updating webhook for department: ${id}`);
|
||||
|
||||
const department = await this.findById(id);
|
||||
@@ -229,11 +218,7 @@ export class DepartmentsService {
|
||||
* @param endDate - End date for statistics
|
||||
* @returns Promise<DepartmentStatsDto>
|
||||
*/
|
||||
async getStats(
|
||||
code: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<DepartmentStatsDto> {
|
||||
async getStats(code: string, startDate?: Date, endDate?: Date): Promise<DepartmentStatsDto> {
|
||||
this.logger.debug(
|
||||
`Fetching statistics for department: ${code} from ${startDate} to ${endDate}`,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
IsUrl,
|
||||
IsEmail,
|
||||
} from 'class-validator';
|
||||
import { IsString, Matches, MinLength, IsOptional, IsUrl, IsEmail } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateDepartmentDto {
|
||||
|
||||
@@ -58,8 +58,7 @@ export class DepartmentStatsDto {
|
||||
|
||||
constructor() {
|
||||
if (this.totalApplicants > 0) {
|
||||
this.issueRate =
|
||||
(this.totalCredentialsIssued / this.totalApplicants) * 100;
|
||||
this.issueRate = (this.totalCredentialsIssued / this.totalApplicants) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDepartmentDto } from './create-department.dto';
|
||||
import {
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
IsUrl,
|
||||
IsEmail,
|
||||
} from 'class-validator';
|
||||
import { IsString, Matches, MinLength, IsOptional, IsUrl, IsEmail } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {
|
||||
|
||||
@@ -75,7 +75,12 @@ export class DocumentsController {
|
||||
})
|
||||
async uploadDocumentAlt(
|
||||
@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,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<any> {
|
||||
@@ -99,7 +104,7 @@ export class DocumentsController {
|
||||
uploadDto.requestId,
|
||||
file,
|
||||
{ docType: uploadDto.documentType, description: uploadDto.description },
|
||||
user.sub
|
||||
user.sub,
|
||||
);
|
||||
|
||||
const storagePath = `requests/${uploadDto.requestId}/${uploadDto.documentType}`;
|
||||
@@ -222,6 +227,10 @@ export class DocumentsController {
|
||||
status: 404,
|
||||
description: 'Document not found',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Access denied',
|
||||
})
|
||||
async downloadDocument(
|
||||
@Param('documentId') documentId: string,
|
||||
@Query('version') version: string,
|
||||
@@ -230,10 +239,27 @@ export class DocumentsController {
|
||||
@CorrelationId() correlationId: string,
|
||||
@Res() res: any,
|
||||
): 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
|
||||
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) {
|
||||
throw new ForbiddenException({
|
||||
code: 'AUTH_008',
|
||||
@@ -241,16 +267,41 @@ export class DocumentsController {
|
||||
});
|
||||
}
|
||||
|
||||
const versionNum = version ? parseInt(version) : undefined;
|
||||
const { buffer, mimeType, fileName, fileSize, hash } = await this.documentsService.getFileContent(documentId, versionNum, user.sub);
|
||||
const versionNum = version ? parseInt(version, 10) : undefined;
|
||||
|
||||
// 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';
|
||||
|
||||
res.set({
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': fileSize,
|
||||
'Content-Disposition': `${disposition}; filename="${fileName}"`,
|
||||
'X-File-Hash': hash,
|
||||
'Content-Type': mimeType || 'application/octet-stream',
|
||||
'Content-Length': fileSize || buffer.length,
|
||||
'Content-Disposition': `${disposition}; filename="${sanitizedFileName}"`,
|
||||
'X-File-Hash': hash || '',
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
@@ -284,7 +335,7 @@ export class DocumentsController {
|
||||
this.logger.debug(`[${correlationId}] Fetching versions for document: ${documentId}`);
|
||||
|
||||
const versions = await this.documentsService.getVersions(documentId);
|
||||
return versions.map((v) => ({
|
||||
return versions.map(v => ({
|
||||
id: v.id,
|
||||
documentId: v.documentId,
|
||||
version: v.version,
|
||||
@@ -340,9 +391,7 @@ export class DocumentsController {
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<DocumentResponseDto> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Uploading new version for document: ${documentId}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Uploading new version for document: ${documentId}`);
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('File is required');
|
||||
@@ -405,10 +454,26 @@ export class DocumentsController {
|
||||
this.logger.debug(`[${correlationId}] Fetching documents for request: ${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 {
|
||||
// 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 {
|
||||
id: document.id,
|
||||
requestId: document.requestId,
|
||||
@@ -417,6 +482,8 @@ export class DocumentsController {
|
||||
currentVersion: document.currentVersion,
|
||||
currentHash: document.currentHash,
|
||||
fileHash: document.currentHash,
|
||||
fileSize,
|
||||
mimeType,
|
||||
minioBucket: document.minioBucket,
|
||||
isActive: document.isActive,
|
||||
downloadCount: document.downloadCount || 0,
|
||||
|
||||
@@ -6,10 +6,7 @@ import { MinioService } from './services/minio.service';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
AuditModule,
|
||||
],
|
||||
imports: [ConfigModule, AuditModule],
|
||||
controllers: [DocumentsController],
|
||||
providers: [DocumentsService, MinioService],
|
||||
exports: [DocumentsService, MinioService],
|
||||
|
||||
@@ -24,9 +24,19 @@ export class DocumentsService {
|
||||
private readonly MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
private readonly ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
private readonly ALLOWED_DOC_TYPES = [
|
||||
'FLOOR_PLAN', 'PHOTOGRAPH', 'ID_PROOF', 'ADDRESS_PROOF',
|
||||
'NOC', 'LICENSE_COPY', 'OTHER', 'FIRE_SAFETY', 'HEALTH_CERT',
|
||||
'TAX_CLEARANCE', 'SITE_PLAN', 'BUILDING_PERMIT', 'BUSINESS_LICENSE'
|
||||
'FLOOR_PLAN',
|
||||
'PHOTOGRAPH',
|
||||
'ID_PROOF',
|
||||
'ADDRESS_PROOF',
|
||||
'NOC',
|
||||
'LICENSE_COPY',
|
||||
'OTHER',
|
||||
'FIRE_SAFETY',
|
||||
'HEALTH_CERT',
|
||||
'TAX_CLEARANCE',
|
||||
'SITE_PLAN',
|
||||
'BUILDING_PERMIT',
|
||||
'BUSINESS_LICENSE',
|
||||
];
|
||||
|
||||
constructor(
|
||||
@@ -56,7 +66,10 @@ export class DocumentsService {
|
||||
.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);
|
||||
if (!user) {
|
||||
return { canAccess: false, isAdmin: false };
|
||||
@@ -67,7 +80,8 @@ export class DocumentsService {
|
||||
return { canAccess: true, isAdmin: true };
|
||||
}
|
||||
|
||||
const request = await this.requestRepository.query()
|
||||
const request = await this.requestRepository
|
||||
.query()
|
||||
.findById(requestId)
|
||||
.withGraphFetched('applicant');
|
||||
|
||||
@@ -83,7 +97,8 @@ export class DocumentsService {
|
||||
|
||||
// Check if user is from an assigned department
|
||||
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 });
|
||||
if (approvals.length > 0) {
|
||||
return { canAccess: true, isAdmin: false };
|
||||
@@ -134,7 +149,9 @@ export class DocumentsService {
|
||||
'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 currentVersion = 1;
|
||||
@@ -206,7 +223,10 @@ export class DocumentsService {
|
||||
async findById(id: string): Promise<Document> {
|
||||
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) {
|
||||
throw new NotFoundException(`Document not found: ${id}`);
|
||||
@@ -218,7 +238,8 @@ export class DocumentsService {
|
||||
async findByRequestId(requestId: string): Promise<Document[]> {
|
||||
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 })
|
||||
.withGraphFetched('versions')
|
||||
.orderBy('created_at', 'DESC');
|
||||
@@ -229,7 +250,8 @@ export class DocumentsService {
|
||||
|
||||
await this.findById(documentId);
|
||||
|
||||
return await this.documentVersionRepository.query()
|
||||
return await this.documentVersionRepository
|
||||
.query()
|
||||
.where({ document_id: documentId })
|
||||
.orderBy('version', 'DESC');
|
||||
}
|
||||
@@ -251,7 +273,10 @@ export class DocumentsService {
|
||||
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}`);
|
||||
|
||||
const document = await this.findById(documentId);
|
||||
@@ -259,13 +284,16 @@ export class DocumentsService {
|
||||
let targetVersion: DocumentVersion;
|
||||
|
||||
if (version) {
|
||||
targetVersion = await this.documentVersionRepository.query().findOne({ document_id: documentId, version });
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.findOne({ document_id: documentId, version });
|
||||
|
||||
if (!targetVersion) {
|
||||
throw new NotFoundException(`Document version not found: ${version}`);
|
||||
}
|
||||
} else {
|
||||
targetVersion = await this.documentVersionRepository.query()
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.where({ document_id: documentId })
|
||||
.orderBy('version', 'DESC')
|
||||
.first();
|
||||
@@ -285,7 +313,11 @@ export class DocumentsService {
|
||||
return { url, expiresAt };
|
||||
}
|
||||
|
||||
async getFileContent(documentId: string, version?: number, userId?: string): Promise<{
|
||||
async getFileContent(
|
||||
documentId: string,
|
||||
version?: number,
|
||||
userId?: string,
|
||||
): Promise<{
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
@@ -299,13 +331,16 @@ export class DocumentsService {
|
||||
let targetVersion: DocumentVersion;
|
||||
|
||||
if (version) {
|
||||
targetVersion = await this.documentVersionRepository.query().findOne({ document_id: documentId, version });
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.findOne({ document_id: documentId, version });
|
||||
|
||||
if (!targetVersion) {
|
||||
throw new NotFoundException(`Document version not found: ${version}`);
|
||||
}
|
||||
} else {
|
||||
targetVersion = await this.documentVersionRepository.query()
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.where({ document_id: documentId })
|
||||
.orderBy('version', 'DESC')
|
||||
.first();
|
||||
@@ -316,17 +351,53 @@ export class DocumentsService {
|
||||
}
|
||||
|
||||
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
|
||||
const currentCount = document.downloadCount || 0;
|
||||
try {
|
||||
await document.$query().patch({
|
||||
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
|
||||
if (userId) {
|
||||
try {
|
||||
await this.auditService.record({
|
||||
entityType: 'REQUEST',
|
||||
entityId: document.requestId,
|
||||
@@ -341,13 +412,19 @@ export class DocumentsService {
|
||||
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 {
|
||||
buffer,
|
||||
mimeType: targetVersion.mimeType,
|
||||
mimeType: targetVersion.mimeType || 'application/octet-stream',
|
||||
fileName: document.originalFilename,
|
||||
fileSize: parseInt(targetVersion.fileSize),
|
||||
fileSize: parseInt(targetVersion.fileSize) || buffer.length,
|
||||
hash: targetVersion.hash,
|
||||
};
|
||||
}
|
||||
@@ -380,7 +457,8 @@ export class DocumentsService {
|
||||
|
||||
const document = await this.findById(documentId);
|
||||
|
||||
const approvals = await this.approvalRepository.query()
|
||||
const approvals = await this.approvalRepository
|
||||
.query()
|
||||
.where({
|
||||
request_id: document.requestId,
|
||||
status: ApprovalStatus.APPROVED,
|
||||
@@ -418,7 +496,7 @@ export class DocumentsService {
|
||||
private validateDocumentType(docType: string): void {
|
||||
if (!this.ALLOWED_DOC_TYPES.includes(docType)) {
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ export class DocumentResponseDto {
|
||||
})
|
||||
fileHash?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'File size in bytes',
|
||||
required: false,
|
||||
})
|
||||
fileSize?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'MIME type of the file',
|
||||
required: false,
|
||||
})
|
||||
mimeType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'MinIO bucket name',
|
||||
})
|
||||
|
||||
@@ -2,16 +2,19 @@ import { IsString, IsIn, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export const ALLOWED_DOC_TYPES = [
|
||||
'FIRE_SAFETY_CERTIFICATE',
|
||||
'BUILDING_PLAN',
|
||||
'PROPERTY_OWNERSHIP',
|
||||
'INSPECTION_REPORT',
|
||||
'POLLUTION_CERTIFICATE',
|
||||
'ELECTRICAL_SAFETY_CERTIFICATE',
|
||||
'STRUCTURAL_STABILITY_CERTIFICATE',
|
||||
'IDENTITY_PROOF',
|
||||
'FLOOR_PLAN',
|
||||
'PHOTOGRAPH',
|
||||
'ID_PROOF',
|
||||
'ADDRESS_PROOF',
|
||||
'NOC',
|
||||
'LICENSE_COPY',
|
||||
'OTHER',
|
||||
'FIRE_SAFETY',
|
||||
'HEALTH_CERT',
|
||||
'TAX_CLEARANCE',
|
||||
'SITE_PLAN',
|
||||
'BUILDING_PERMIT',
|
||||
'BUSINESS_LICENSE',
|
||||
];
|
||||
|
||||
export class UploadDocumentDto {
|
||||
|
||||
@@ -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 * as Minio from 'minio';
|
||||
|
||||
@@ -16,10 +21,7 @@ export class MinioService {
|
||||
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin');
|
||||
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin');
|
||||
|
||||
this.defaultBucket = this.configService.get<string>(
|
||||
'MINIO_BUCKET',
|
||||
'goa-gel-documents',
|
||||
);
|
||||
this.defaultBucket = this.configService.get<string>('MINIO_BUCKET', 'goa-gel-documents');
|
||||
this.region = this.configService.get<string>('MINIO_REGION', 'us-east-1');
|
||||
|
||||
this.minioClient = new Minio.Client({
|
||||
@@ -31,9 +33,7 @@ export class MinioService {
|
||||
region: this.region,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`MinIO client initialized: ${endPoint}:${port}, bucket: ${this.defaultBucket}`,
|
||||
);
|
||||
this.logger.log(`MinIO client initialized: ${endPoint}:${port}, bucket: ${this.defaultBucket}`);
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
@@ -61,18 +61,32 @@ export class MinioService {
|
||||
}
|
||||
}
|
||||
|
||||
async getSignedUrl(
|
||||
bucket: string,
|
||||
path: string,
|
||||
expiresIn: number = 3600,
|
||||
): Promise<string> {
|
||||
async getSignedUrl(bucket: string, path: string, expiresIn: number = 3600): Promise<string> {
|
||||
this.logger.debug(`Generating signed URL for: ${bucket}/${path}`);
|
||||
|
||||
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);
|
||||
this.logger.debug(`Signed URL generated successfully`);
|
||||
return url;
|
||||
} catch (error: any) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Failed to generate signed URL: ${error.message}`);
|
||||
throw new InternalServerErrorException('Failed to generate download URL');
|
||||
}
|
||||
@@ -114,6 +128,14 @@ export class MinioService {
|
||||
const stat = await this.minioClient.statObject(bucket, path);
|
||||
return stat;
|
||||
} 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}`);
|
||||
throw new InternalServerErrorException('Failed to retrieve file metadata');
|
||||
}
|
||||
@@ -123,16 +145,45 @@ export class MinioService {
|
||||
this.logger.debug(`Fetching file from MinIO: ${bucket}/${path}`);
|
||||
|
||||
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 chunks: Buffer[] = [];
|
||||
|
||||
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('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) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -163,4 +214,39 @@ export class MinioService {
|
||||
getDefaultBucket(): string {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { Controller, Get, Inject, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { KNEX_CONNECTION } from '../../database/database.module';
|
||||
import type Knex from 'knex';
|
||||
|
||||
@@ -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 { ApiProperty } from '@nestjs/swagger';
|
||||
import { RequestType } from '../enums/request-type.enum';
|
||||
|
||||
@@ -82,7 +82,7 @@ export class RequestsController {
|
||||
this.logger.debug(`[${correlationId}] Creating new request for user: ${user.email}`);
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export class RequestsController {
|
||||
this.logger.debug(`[${correlationId}] Fetching license 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 });
|
||||
if (!applicant) {
|
||||
throw new BadRequestException('No applicant profile found for user');
|
||||
@@ -166,15 +166,15 @@ export class RequestsController {
|
||||
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 {
|
||||
data: results.map((r) => this.mapToResponseDto(r)),
|
||||
meta: {
|
||||
data: data.map(r => this.mapToResponseDto(r)),
|
||||
total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalPages: Math.ceil(total / query.limit),
|
||||
},
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,9 +206,9 @@ export class RequestsController {
|
||||
this.logger.debug(`[${correlationId}] Fetching pending requests`);
|
||||
|
||||
const deptCode = 'FIRE_SAFETY';
|
||||
const { results, total } = await this.requestsService.findPendingForDepartment(deptCode, query);
|
||||
const { data, total } = await this.requestsService.findPendingForDepartment(deptCode, query);
|
||||
return {
|
||||
data: results.map((r) => this.mapToResponseDto(r)),
|
||||
data: data.map(r => this.mapToResponseDto(r)),
|
||||
meta: {
|
||||
total,
|
||||
page: query.page,
|
||||
@@ -246,7 +246,7 @@ export class RequestsController {
|
||||
const request = await this.requestsService.findById(id);
|
||||
|
||||
// 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 });
|
||||
if (!applicant) {
|
||||
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
|
||||
if (user.role === UserRole.DEPARTMENT && user.departmentCode) {
|
||||
const approvals = (request as any).approvals;
|
||||
const hasApproval = Array.isArray(approvals) && approvals.some((a: any) =>
|
||||
(a as any).department?.code === user.departmentCode
|
||||
);
|
||||
// Check if any approval is for this department
|
||||
// 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) {
|
||||
throw new ForbiddenException('You can only view requests assigned to your department');
|
||||
}
|
||||
@@ -475,7 +487,8 @@ export class RequestsController {
|
||||
if (Array.isArray(approvals)) {
|
||||
const pendingApproval = approvals.find((a: any) => a.status === 'PENDING');
|
||||
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)) {
|
||||
const pendingApproval = approvals.find((a: any) => a.status === 'PENDING');
|
||||
if (pendingApproval) {
|
||||
assignedDepartment = (pendingApproval as any).department?.code || pendingApproval.departmentId;
|
||||
assignedDepartment =
|
||||
(pendingApproval as any).department?.code || pendingApproval.departmentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +585,8 @@ export class RequestsController {
|
||||
formData: metadata,
|
||||
blockchainTxHash: request.blockchainTxHash,
|
||||
tokenId: request.tokenId,
|
||||
documents: (request.documents as any)?.map((d: any) => ({
|
||||
documents:
|
||||
(request.documents as any)?.map((d: any) => ({
|
||||
id: d.id,
|
||||
docType: d.docType,
|
||||
originalFilename: d.originalFilename,
|
||||
@@ -582,7 +597,8 @@ export class RequestsController {
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
})) || [],
|
||||
approvals: (request.approvals as any)?.map((a: any) => ({
|
||||
approvals:
|
||||
(request.approvals as any)?.map((a: any) => ({
|
||||
id: a.id,
|
||||
departmentId: a.departmentId,
|
||||
status: a.status,
|
||||
@@ -597,18 +613,22 @@ export class RequestsController {
|
||||
updatedAt: request.updatedAt,
|
||||
submittedAt: request.submittedAt,
|
||||
approvedAt: request.approvedAt,
|
||||
workflow: workflow ? {
|
||||
workflow: workflow
|
||||
? {
|
||||
id: workflow.id,
|
||||
code: workflow.workflowType,
|
||||
name: workflow.name,
|
||||
steps: workflow.definition?.steps || [],
|
||||
} : undefined,
|
||||
applicant: applicant ? {
|
||||
}
|
||||
: undefined,
|
||||
applicant: applicant
|
||||
? {
|
||||
id: applicant.id,
|
||||
email: applicant.email,
|
||||
name: applicant.name,
|
||||
walletAddress: applicant.walletAddress || '',
|
||||
} : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return plainToInstance(RequestDetailResponseDto, result, { excludeExtraneousValues: false });
|
||||
|
||||
@@ -48,7 +48,9 @@ export class RequestsService {
|
||||
// Handle workflow lookup by code if provided
|
||||
let workflowId = dto.workflowId;
|
||||
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 })
|
||||
.first();
|
||||
|
||||
@@ -74,7 +76,18 @@ export class RequestsService {
|
||||
|
||||
// Add any remaining top-level fields that aren't part of the core DTO as metadata
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +104,9 @@ export class RequestsService {
|
||||
// Load workflow relation for response
|
||||
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;
|
||||
} catch (error: any) {
|
||||
@@ -169,20 +184,24 @@ export class RequestsService {
|
||||
|
||||
// Map camelCase to snake_case for database columns
|
||||
const sortMap: Record<string, string> = {
|
||||
'createdAt': 'created_at',
|
||||
'updatedAt': 'updated_at',
|
||||
'requestNumber': 'request_number',
|
||||
'status': 'status',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
requestNumber: 'request_number',
|
||||
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
|
||||
// When filtering by department, only load approvals for that department
|
||||
if (departmentCode) {
|
||||
queryBuilder.withGraphFetched('[workflow, approvals(pendingForDept).department, workflowState]')
|
||||
queryBuilder
|
||||
.withGraphFetched('[workflow, approvals(pendingForDept).department, workflowState]')
|
||||
.modifiers({
|
||||
pendingForDept: (builder) => {
|
||||
pendingForDept: builder => {
|
||||
builder
|
||||
.where('approvals.status', ApprovalStatus.PENDING)
|
||||
.joinRelated('department')
|
||||
@@ -193,16 +212,20 @@ export class RequestsService {
|
||||
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> {
|
||||
this.logger.debug(`Finding license request: ${id}`);
|
||||
|
||||
const request = await this.requestRepository.query()
|
||||
const request = await this.requestRepository
|
||||
.query()
|
||||
.select('license_requests.*')
|
||||
.findById(id)
|
||||
.withGraphFetched('[applicant, workflow, documents, documents.versions, approvals.department, workflowState]');
|
||||
.withGraphFetched(
|
||||
'[applicant, workflow, documents, documents.versions, approvals.department, workflowState]',
|
||||
);
|
||||
|
||||
if (!request) {
|
||||
throw new NotFoundException(`License request not found: ${id}`);
|
||||
@@ -214,7 +237,8 @@ export class RequestsService {
|
||||
async findByRequestNumber(requestNumber: string): Promise<LicenseRequest> {
|
||||
this.logger.debug(`Finding license request by number: ${requestNumber}`);
|
||||
|
||||
const request = await this.requestRepository.query()
|
||||
const request = await this.requestRepository
|
||||
.query()
|
||||
.findOne({ requestNumber })
|
||||
.withGraphFetched('[applicant, workflow, documents, approvals]');
|
||||
|
||||
@@ -233,7 +257,8 @@ export class RequestsService {
|
||||
|
||||
const { page = 1, limit = 20 } = query;
|
||||
|
||||
const requests = await this.requestRepository.query()
|
||||
const requests = await this.requestRepository
|
||||
.query()
|
||||
.joinRelated('approvals.department')
|
||||
.where('approvals.status', ApprovalStatus.PENDING)
|
||||
.where('department.code', deptCode)
|
||||
@@ -241,9 +266,11 @@ export class RequestsService {
|
||||
.page(page - 1, limit)
|
||||
.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> {
|
||||
@@ -259,7 +286,8 @@ export class RequestsService {
|
||||
[LicenseRequestStatus.REJECTED]: 'Request already rejected',
|
||||
[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}`;
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
@@ -287,7 +315,8 @@ export class RequestsService {
|
||||
if (firstStage && firstStage.requiredApprovals?.length > 0) {
|
||||
// Create approval records for each department in the first stage
|
||||
for (const deptApproval of firstStage.requiredApprovals) {
|
||||
const department = await this.departmentRepository.query()
|
||||
const department = await this.departmentRepository
|
||||
.query()
|
||||
.findOne({ code: deptApproval.departmentCode });
|
||||
|
||||
if (department) {
|
||||
@@ -298,15 +327,16 @@ export class RequestsService {
|
||||
isActive: true,
|
||||
});
|
||||
} 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)
|
||||
const mockTxHash = '0x' + Array.from({ length: 64 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('');
|
||||
const mockTxHash =
|
||||
'0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
|
||||
await request.$query().patch({
|
||||
status: LicenseRequestStatus.SUBMITTED,
|
||||
@@ -339,15 +369,21 @@ export class RequestsService {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Generate blockchain transaction hash for submitted requests
|
||||
const isSubmitted = request.status === LicenseRequestStatus.SUBMITTED ||
|
||||
const isSubmitted =
|
||||
request.status === LicenseRequestStatus.SUBMITTED ||
|
||||
request.status === LicenseRequestStatus.IN_REVIEW;
|
||||
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;
|
||||
|
||||
const metadataUpdate: any = {
|
||||
@@ -453,7 +489,8 @@ export class RequestsService {
|
||||
});
|
||||
}
|
||||
|
||||
const approvals = await this.approvalRepository.query()
|
||||
const approvals = await this.approvalRepository
|
||||
.query()
|
||||
.where({ requestId: request.id })
|
||||
.withGraphFetched('department')
|
||||
.orderBy('updated_at', 'DESC');
|
||||
@@ -515,10 +552,12 @@ export class RequestsService {
|
||||
'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 missing = requiredDocTypes.filter((dt) => !uploadedDocTypes.includes(dt));
|
||||
const uploadedDocTypes = documents.map(d => d.docType);
|
||||
const missing = requiredDocTypes.filter(dt => !uploadedDocTypes.includes(dt));
|
||||
|
||||
const valid = missing.length === 0;
|
||||
|
||||
@@ -534,7 +573,10 @@ export class RequestsService {
|
||||
const currentStatus = request.status as LicenseRequestStatus;
|
||||
|
||||
const validTransitions: Record<LicenseRequestStatus, LicenseRequestStatus[]> = {
|
||||
[LicenseRequestStatus.DRAFT]: [LicenseRequestStatus.SUBMITTED, LicenseRequestStatus.CANCELLED],
|
||||
[LicenseRequestStatus.DRAFT]: [
|
||||
LicenseRequestStatus.SUBMITTED,
|
||||
LicenseRequestStatus.CANCELLED,
|
||||
],
|
||||
[LicenseRequestStatus.SUBMITTED]: [
|
||||
LicenseRequestStatus.IN_REVIEW,
|
||||
LicenseRequestStatus.CANCELLED,
|
||||
@@ -555,9 +597,7 @@ export class RequestsService {
|
||||
};
|
||||
|
||||
if (!validTransitions[currentStatus]?.includes(newStatus as LicenseRequestStatus)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot transition from ${currentStatus} to ${newStatus}`,
|
||||
);
|
||||
throw new BadRequestException(`Cannot transition from ${currentStatus} to ${newStatus}`);
|
||||
}
|
||||
|
||||
const patch: Partial<LicenseRequest> = { status: newStatus as LicenseRequestStatus };
|
||||
|
||||
@@ -19,9 +19,7 @@ export class WebhooksService {
|
||||
|
||||
async register(departmentId: string, dto: CreateWebhookDto): Promise<Webhook> {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Registering webhook for department: ${departmentId}, URL: ${dto.url}`,
|
||||
);
|
||||
this.logger.debug(`Registering webhook for department: ${departmentId}, URL: ${dto.url}`);
|
||||
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
@@ -43,7 +41,8 @@ export class WebhooksService {
|
||||
|
||||
async findAll(departmentId: string): Promise<Webhook[]> {
|
||||
try {
|
||||
return await this.webhookRepository.query()
|
||||
return await this.webhookRepository
|
||||
.query()
|
||||
.where({ departmentId })
|
||||
.orderBy('created_at', 'DESC');
|
||||
} 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> {
|
||||
try {
|
||||
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 {
|
||||
await this.findById(webhookId);
|
||||
|
||||
const query = this.webhookLogRepository.query()
|
||||
const query = this.webhookLogRepository
|
||||
.query()
|
||||
.where({ webhookId })
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
@@ -183,10 +197,7 @@ export class WebhooksService {
|
||||
|
||||
verifySignature(payload: object, signature: string, secret: string): boolean {
|
||||
const expectedSignature = this.generateSignature(payload, secret);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||
}
|
||||
|
||||
async logWebhookAttempt(
|
||||
|
||||
@@ -40,6 +40,31 @@ export class WebhooksController {
|
||||
|
||||
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')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
@@ -54,10 +79,7 @@ export class WebhooksController {
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid webhook data' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async register(
|
||||
@Param('departmentId') departmentId: string,
|
||||
@Body() dto: CreateWebhookDto,
|
||||
) {
|
||||
async register(@Param('departmentId') departmentId: string, @Body() dto: CreateWebhookDto) {
|
||||
this.logger.debug(`Registering webhook for department: ${departmentId}`);
|
||||
return this.webhooksService.register(departmentId, dto);
|
||||
}
|
||||
@@ -148,14 +170,21 @@ export class WebhooksController {
|
||||
description: 'Get paginated delivery logs for a specific webhook',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Webhook ID (UUID)' })
|
||||
@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)' })
|
||||
@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: 'Webhook delivery logs' })
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||
async getLogs(
|
||||
@Param('id') id: string,
|
||||
@Query() pagination: PaginationDto,
|
||||
) {
|
||||
async getLogs(@Param('id') id: string, @Query() pagination: PaginationDto) {
|
||||
return this.webhooksService.getLogs(id, pagination);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { WorkflowStageDto } from './workflow-stage.dto';
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { WorkflowStageDto } from './workflow-stage.dto';
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsOptional,
|
||||
} from 'class-validator';
|
||||
import { IsString, IsNumber, IsEnum, IsArray, ValidateNested, IsOptional } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ExecutionType } from '../enums/execution-type.enum';
|
||||
import { CompletionCriteria } from '../enums/completion-criteria.enum';
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
DocumentUpdateResult,
|
||||
StageCompletionCheck,
|
||||
} from '../interfaces/workflow-process-result.interface';
|
||||
import {
|
||||
WorkflowDefinition,
|
||||
WorkflowStage,
|
||||
} from '../interfaces/workflow-definition.interface';
|
||||
import { WorkflowDefinition, WorkflowStage } from '../interfaces/workflow-definition.interface';
|
||||
import { WorkflowAction } from '../enums/workflow-action.enum';
|
||||
import { CompletionCriteria } from '../enums/completion-criteria.enum';
|
||||
import { RejectionHandling } from '../enums/rejection-handling.enum';
|
||||
@@ -35,17 +32,14 @@ export class WorkflowExecutorService {
|
||||
/**
|
||||
* Initialize workflow state for a new request
|
||||
*/
|
||||
async initializeWorkflow(
|
||||
requestId: string,
|
||||
workflowId: string,
|
||||
): Promise<WorkflowState> {
|
||||
async initializeWorkflow(requestId: string, workflowId: string): Promise<WorkflowState> {
|
||||
const workflow = await this.workflowRepository.query().findById(workflowId);
|
||||
|
||||
if (!workflow || !workflow.isActive) {
|
||||
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];
|
||||
|
||||
if (!firstStage) {
|
||||
@@ -53,7 +47,7 @@ export class WorkflowExecutorService {
|
||||
}
|
||||
|
||||
const pendingApprovals: PendingApproval[] = (firstStage.requiredApprovals || []).map(
|
||||
(approval) => ({
|
||||
approval => ({
|
||||
departmentCode: approval.departmentCode,
|
||||
departmentName: approval.departmentName,
|
||||
approvalId: '', // Will be populated when approval records are created
|
||||
@@ -88,7 +82,11 @@ export class WorkflowExecutorService {
|
||||
* Get workflow state
|
||||
*/
|
||||
async getWorkflowState(requestId: string): Promise<WorkflowState | null> {
|
||||
const stateEntity = await (this.workflowStateRepository.constructor as typeof WorkflowStateModel).query().findOne({
|
||||
const stateEntity = await (
|
||||
this.workflowStateRepository.constructor as typeof WorkflowStateModel
|
||||
)
|
||||
.query()
|
||||
.findOne({
|
||||
requestId,
|
||||
});
|
||||
|
||||
@@ -99,7 +97,11 @@ export class WorkflowExecutorService {
|
||||
* Save workflow state
|
||||
*/
|
||||
async saveWorkflowState(state: WorkflowState): Promise<void> {
|
||||
const stateEntity = await (this.workflowStateRepository.constructor as typeof WorkflowStateModel).query().findOne({
|
||||
const stateEntity = await (
|
||||
this.workflowStateRepository.constructor as typeof WorkflowStateModel
|
||||
)
|
||||
.query()
|
||||
.findOne({
|
||||
requestId: state.requestId,
|
||||
});
|
||||
|
||||
@@ -133,14 +135,10 @@ export class WorkflowExecutorService {
|
||||
}
|
||||
|
||||
// Update pending approval status
|
||||
const pendingApproval = state.pendingApprovals.find(
|
||||
(pa) => pa.departmentCode === departmentCode,
|
||||
);
|
||||
const pendingApproval = state.pendingApprovals.find(pa => pa.departmentCode === departmentCode);
|
||||
|
||||
if (!pendingApproval) {
|
||||
throw new BadRequestException(
|
||||
`No pending approval found for department ${departmentCode}`,
|
||||
);
|
||||
throw new BadRequestException(`No pending approval found for department ${departmentCode}`);
|
||||
}
|
||||
|
||||
pendingApproval.status = approvalStatus;
|
||||
@@ -155,16 +153,12 @@ export class WorkflowExecutorService {
|
||||
|
||||
if (stageComplete.isComplete) {
|
||||
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);
|
||||
|
||||
if (stageComplete.rejectionCount > 0) {
|
||||
// Handle rejection based on stage configuration
|
||||
const rejectionAction = await this.handleRejection(
|
||||
state,
|
||||
currentStage,
|
||||
stageComplete,
|
||||
);
|
||||
const rejectionAction = await this.handleRejection(state, currentStage, stageComplete);
|
||||
actions.push(...rejectionAction.actions);
|
||||
failureReason = rejectionAction.failureReason;
|
||||
|
||||
@@ -205,14 +199,12 @@ export class WorkflowExecutorService {
|
||||
actions,
|
||||
actionsMetadata: {
|
||||
currentStageId: state.currentStageId,
|
||||
nextDepartments: stageAdvanced
|
||||
? state.pendingApprovals.map((pa) => pa.departmentCode)
|
||||
: [],
|
||||
nextDepartments: stageAdvanced ? state.pendingApprovals.map(pa => pa.departmentCode) : [],
|
||||
},
|
||||
stageAdvanced,
|
||||
workflowCompleted,
|
||||
failureReason,
|
||||
nextDepartments: state.pendingApprovals.map((pa) => pa.departmentCode),
|
||||
nextDepartments: state.pendingApprovals.map(pa => pa.departmentCode),
|
||||
message: `Approval processed for department ${departmentCode}`,
|
||||
};
|
||||
}
|
||||
@@ -222,22 +214,20 @@ export class WorkflowExecutorService {
|
||||
*/
|
||||
async isStageComplete(state: WorkflowState): Promise<StageCompletionCheck> {
|
||||
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 pendingApprovals = state.pendingApprovals;
|
||||
|
||||
const approvedCount = pendingApprovals.filter(
|
||||
(pa) => pa.status === ApprovalStatus.APPROVED,
|
||||
pa => pa.status === ApprovalStatus.APPROVED,
|
||||
).length;
|
||||
|
||||
const rejectionCount = pendingApprovals.filter(
|
||||
(pa) => pa.status === ApprovalStatus.REJECTED,
|
||||
pa => pa.status === ApprovalStatus.REJECTED,
|
||||
).length;
|
||||
|
||||
const pendingCount = pendingApprovals.filter(
|
||||
(pa) => pa.status === ApprovalStatus.PENDING,
|
||||
).length;
|
||||
const pendingCount = pendingApprovals.filter(pa => pa.status === ApprovalStatus.PENDING).length;
|
||||
|
||||
const totalRequired = pendingApprovals.length;
|
||||
|
||||
@@ -257,8 +247,7 @@ export class WorkflowExecutorService {
|
||||
case CompletionCriteria.THRESHOLD:
|
||||
// Minimum threshold met
|
||||
const threshold = currentStage.threshold || 1;
|
||||
isComplete =
|
||||
approvedCount >= threshold || (approvedCount + pendingCount < threshold);
|
||||
isComplete = approvedCount >= threshold || approvedCount + pendingCount < threshold;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -279,7 +268,7 @@ export class WorkflowExecutorService {
|
||||
nextStage: WorkflowStage,
|
||||
): Promise<WorkflowState> {
|
||||
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);
|
||||
|
||||
// Mark current stage as complete
|
||||
@@ -298,7 +287,7 @@ export class WorkflowExecutorService {
|
||||
state.currentStageOrder = nextStage.stageOrder;
|
||||
|
||||
// Initialize pending approvals for new stage
|
||||
state.pendingApprovals = nextStage.requiredApprovals.map((approval) => ({
|
||||
state.pendingApprovals = nextStage.requiredApprovals.map(approval => ({
|
||||
departmentCode: approval.departmentCode,
|
||||
departmentName: approval.departmentName,
|
||||
approvalId: '',
|
||||
@@ -311,10 +300,7 @@ export class WorkflowExecutorService {
|
||||
/**
|
||||
* Handle document update - invalidate approvals
|
||||
*/
|
||||
async handleDocumentUpdate(
|
||||
requestId: string,
|
||||
documentId: string,
|
||||
): Promise<DocumentUpdateResult> {
|
||||
async handleDocumentUpdate(requestId: string, documentId: string): Promise<DocumentUpdateResult> {
|
||||
const state = await this.getWorkflowState(requestId);
|
||||
|
||||
if (!state || state.isWorkflowComplete) {
|
||||
@@ -326,8 +312,7 @@ export class WorkflowExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
const affectedDepartments =
|
||||
await this.approvalsService.invalidateApprovalsByDocument(
|
||||
const affectedDepartments = await this.approvalsService.invalidateApprovalsByDocument(
|
||||
requestId,
|
||||
documentId,
|
||||
'Document was updated',
|
||||
@@ -335,9 +320,7 @@ export class WorkflowExecutorService {
|
||||
|
||||
// Reset pending approvals for affected departments
|
||||
for (const deptCode of affectedDepartments) {
|
||||
const pendingApproval = state.pendingApprovals.find(
|
||||
(pa) => pa.departmentCode === deptCode,
|
||||
);
|
||||
const pendingApproval = state.pendingApprovals.find(pa => pa.departmentCode === deptCode);
|
||||
|
||||
if (pendingApproval) {
|
||||
pendingApproval.status = ApprovalStatus.PENDING;
|
||||
@@ -358,10 +341,7 @@ export class WorkflowExecutorService {
|
||||
/**
|
||||
* Check if department can approve at current stage
|
||||
*/
|
||||
async canDepartmentApprove(
|
||||
requestId: string,
|
||||
departmentCode: string,
|
||||
): Promise<boolean> {
|
||||
async canDepartmentApprove(requestId: string, departmentCode: string): Promise<boolean> {
|
||||
const state = await this.getWorkflowState(requestId);
|
||||
|
||||
if (!state || state.isWorkflowComplete) {
|
||||
@@ -369,20 +349,18 @@ export class WorkflowExecutorService {
|
||||
}
|
||||
|
||||
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 isInCurrentStage = currentStage.requiredApprovals.some(
|
||||
(ra) => ra.departmentCode === departmentCode,
|
||||
ra => ra.departmentCode === departmentCode,
|
||||
);
|
||||
|
||||
if (!isInCurrentStage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pendingApproval = state.pendingApprovals.find(
|
||||
(pa) => pa.departmentCode === departmentCode,
|
||||
);
|
||||
const pendingApproval = state.pendingApprovals.find(pa => pa.departmentCode === departmentCode);
|
||||
|
||||
return pendingApproval?.status === ApprovalStatus.PENDING;
|
||||
}
|
||||
@@ -398,8 +376,8 @@ export class WorkflowExecutorService {
|
||||
}
|
||||
|
||||
return state.pendingApprovals
|
||||
.filter((pa) => pa.status === ApprovalStatus.PENDING)
|
||||
.map((pa) => pa.departmentCode);
|
||||
.filter(pa => pa.status === ApprovalStatus.PENDING)
|
||||
.map(pa => pa.departmentCode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,7 +448,7 @@ export class WorkflowExecutorService {
|
||||
|
||||
case RejectionHandling.RETRY_STAGE:
|
||||
// Reset all approvals in current stage to PENDING
|
||||
state.pendingApprovals = state.pendingApprovals.map((pa) => ({
|
||||
state.pendingApprovals = state.pendingApprovals.map(pa => ({
|
||||
...pa,
|
||||
status: ApprovalStatus.PENDING,
|
||||
}));
|
||||
@@ -509,16 +487,14 @@ export class WorkflowExecutorService {
|
||||
stage: WorkflowStage,
|
||||
approvals: ApprovalResponseDto[],
|
||||
): boolean {
|
||||
const approvedCount = approvals.filter(
|
||||
(a) => a.status === ApprovalStatus.APPROVED,
|
||||
).length;
|
||||
const approvedCount = approvals.filter(a => a.status === ApprovalStatus.APPROVED).length;
|
||||
|
||||
switch (stage.completionCriteria) {
|
||||
case CompletionCriteria.ALL:
|
||||
return approvals.every((a) => a.status === ApprovalStatus.APPROVED);
|
||||
return approvals.every(a => a.status === ApprovalStatus.APPROVED);
|
||||
|
||||
case CompletionCriteria.ANY:
|
||||
return approvals.some((a) => a.status === ApprovalStatus.APPROVED);
|
||||
return approvals.some(a => a.status === ApprovalStatus.APPROVED);
|
||||
|
||||
case CompletionCriteria.THRESHOLD:
|
||||
return approvedCount >= (stage.threshold || 1);
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
|
||||
|
||||
import { Workflow } from '../../../database/models/workflow.model';
|
||||
import { CreateWorkflowDto } from '../dto/create-workflow.dto';
|
||||
@@ -40,9 +35,7 @@ export class WorkflowsService {
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new BadRequestException(
|
||||
`Workflow validation failed: ${validation.errors.join(', ')}`,
|
||||
);
|
||||
throw new BadRequestException(`Workflow validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
const definition: WorkflowDefinition = {
|
||||
@@ -74,7 +67,7 @@ export class WorkflowsService {
|
||||
const query = this.workflowRepository.query();
|
||||
|
||||
if (isActive !== undefined) {
|
||||
query.where({ isActive });
|
||||
query.where({ is_active: isActive });
|
||||
}
|
||||
|
||||
return query.orderBy('created_at', 'DESC');
|
||||
@@ -97,14 +90,13 @@ export class WorkflowsService {
|
||||
* Get workflow by type
|
||||
*/
|
||||
async findByType(workflowType: string): Promise<Workflow> {
|
||||
const workflow = await this.workflowRepository.query()
|
||||
const workflow = await this.workflowRepository
|
||||
.query()
|
||||
.findOne({ workflowType, isActive: true })
|
||||
.orderBy('version', 'DESC');
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(
|
||||
`No active workflow found for type ${workflowType}`,
|
||||
);
|
||||
throw new NotFoundException(`No active workflow found for type ${workflowType}`);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
@@ -205,9 +197,7 @@ export class WorkflowsService {
|
||||
}
|
||||
|
||||
if (stage.stageOrder !== index) {
|
||||
warnings.push(
|
||||
`Stage ${index} has stageOrder ${stage.stageOrder}, expected ${index}`,
|
||||
);
|
||||
warnings.push(`Stage ${index} has stageOrder ${stage.stageOrder}, expected ${index}`);
|
||||
}
|
||||
|
||||
if (!stage.requiredApprovals || stage.requiredApprovals.length === 0) {
|
||||
@@ -217,26 +207,16 @@ export class WorkflowsService {
|
||||
// Validate completion criteria
|
||||
if (stage.completionCriteria === CompletionCriteria.THRESHOLD) {
|
||||
if (!stage.threshold || stage.threshold < 1) {
|
||||
errors.push(
|
||||
`Stage ${stage.stageId} uses THRESHOLD but no valid threshold is set`,
|
||||
);
|
||||
errors.push(`Stage ${stage.stageId} uses THRESHOLD but no valid threshold is set`);
|
||||
}
|
||||
|
||||
if (
|
||||
stage.threshold &&
|
||||
stage.threshold > stage.requiredApprovals.length
|
||||
) {
|
||||
errors.push(
|
||||
`Stage ${stage.stageId} threshold exceeds number of required approvals`,
|
||||
);
|
||||
if (stage.threshold && stage.threshold > stage.requiredApprovals.length) {
|
||||
errors.push(`Stage ${stage.stageId} threshold exceeds number of required approvals`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejection handling
|
||||
if (
|
||||
stage.rejectionHandling === 'ESCALATE' &&
|
||||
!stage.escalationDepartment
|
||||
) {
|
||||
if (stage.rejectionHandling === 'ESCALATE' && !stage.escalationDepartment) {
|
||||
warnings.push(
|
||||
`Stage ${stage.stageId} uses ESCALATE but no escalationDepartment is configured`,
|
||||
);
|
||||
@@ -262,11 +242,11 @@ export class WorkflowsService {
|
||||
workflowId: workflow.id,
|
||||
workflowType: workflow.workflowType,
|
||||
name: workflow.name,
|
||||
stages: definition.stages.map((stage) => ({
|
||||
stages: definition.stages.map(stage => ({
|
||||
stageId: stage.stageId,
|
||||
stageName: stage.stageName,
|
||||
stageOrder: stage.stageOrder,
|
||||
departments: stage.requiredApprovals.map((ra) => ra.departmentCode),
|
||||
departments: stage.requiredApprovals.map(ra => ra.departmentCode),
|
||||
executionType: stage.executionType,
|
||||
completionCriteria: stage.completionCriteria,
|
||||
})),
|
||||
|
||||
@@ -66,7 +66,8 @@ export class WorkflowsController {
|
||||
@ApiResponse({ status: 200, description: 'List of workflows' })
|
||||
async findAll(@Query('isActive') isActive?: string) {
|
||||
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')
|
||||
|
||||
@@ -5,9 +5,7 @@ import { WorkflowExecutorService } from './services/workflow-executor.service';
|
||||
import { ApprovalsModule } from '../approvals/approvals.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApprovalsModule,
|
||||
],
|
||||
imports: [ApprovalsModule],
|
||||
controllers: [WorkflowsController],
|
||||
providers: [WorkflowsService, WorkflowExecutorService],
|
||||
exports: [WorkflowsService, WorkflowExecutorService],
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)`);
|
||||
});
|
||||
71
convert.js
71
convert.js
@@ -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
142
data-flow.html
142
data-flow.html
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -1,5 +1,22 @@
|
||||
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:
|
||||
# ================================
|
||||
# PostgreSQL Database
|
||||
@@ -11,9 +28,9 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=goa_gel_platform
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres_secure_password
|
||||
POSTGRES_DB: goa_gel_platform
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-postgres_dev_password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -55,8 +72,8 @@ services:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=minioadmin
|
||||
- MINIO_ROOT_PASSWORD=minioadmin_secure
|
||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minio_dev_password}
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
@@ -115,7 +132,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: blockscout
|
||||
POSTGRES_USER: blockscout
|
||||
POSTGRES_PASSWORD: blockscout_secure
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-blockscout_dev_password}
|
||||
volumes:
|
||||
- blockscout_db_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -136,7 +153,7 @@ services:
|
||||
ports:
|
||||
- "4000:4000"
|
||||
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_HTTP_URL: http://besu-node-1:8545
|
||||
ETHEREUM_JSONRPC_WS_URL: ws://besu-node-1:8546
|
||||
@@ -155,7 +172,7 @@ services:
|
||||
POOL_SIZE: 80
|
||||
POOL_SIZE_API: 10
|
||||
ECTO_USE_SSL: "false"
|
||||
SECRET_KEY_BASE: RMgI4C1HSkxsEjdhtGMfwAHfyT6CKWXOgzCboJflfSm4jeAlic52io05KB6mqzc5
|
||||
SECRET_KEY_BASE: ${BLOCKSCOUT_SECRET_KEY_BASE:-RMgI4C1HSkxsEjdhtGMfwAHfyT6CKWXOgzCboJflfSm4jeAlic52io05KB6mqzc5}
|
||||
PORT: 4000
|
||||
DISABLE_EXCHANGE_RATES: "true"
|
||||
SHOW_TXS_CHART: "true"
|
||||
@@ -188,29 +205,40 @@ services:
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- DATABASE_HOST=postgres
|
||||
- DATABASE_PORT=5432
|
||||
- DATABASE_NAME=goa_gel_platform
|
||||
- DATABASE_USER=postgres
|
||||
- DATABASE_PASSWORD=postgres_secure_password
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- MINIO_ENDPOINT=minio
|
||||
- MINIO_PORT=9000
|
||||
- MINIO_ACCESS_KEY=minioadmin
|
||||
- MINIO_SECRET_KEY=minioadmin_secure
|
||||
- MINIO_BUCKET_DOCUMENTS=goa-gel-documents
|
||||
- BESU_RPC_URL=http://besu-node-1:8545
|
||||
- BESU_CHAIN_ID=1337
|
||||
- BESU_NETWORK_ID=2024
|
||||
- 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:-}
|
||||
- JWT_SECRET=${JWT_SECRET:-your-super-secure-jwt-secret-key-min-32-chars-long}
|
||||
# Application
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
PORT: 3001
|
||||
# CORS - set to frontend URL for remote access
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:4200}
|
||||
# Database (must match postgres service)
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_NAME: goa_gel_platform
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-postgres_dev_password}
|
||||
# Redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
# MinIO (must match minio service)
|
||||
MINIO_ENDPOINT: minio
|
||||
MINIO_PORT: 9000
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minio_dev_password}
|
||||
MINIO_BUCKET_DOCUMENTS: goa-gel-documents
|
||||
# Blockchain
|
||||
BESU_RPC_URL: http://besu-node-1:8545
|
||||
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:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -223,7 +251,6 @@ services:
|
||||
networks:
|
||||
- goa-gel-network
|
||||
volumes:
|
||||
- ./backend/.env:/app/.env
|
||||
- api_data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/v1/health"]
|
||||
@@ -243,6 +270,11 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "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:
|
||||
api:
|
||||
condition: service_healthy
|
||||
@@ -255,7 +287,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
# ================================
|
||||
# Documentation Service
|
||||
# Documentation Service (Optional)
|
||||
# ================================
|
||||
documentation:
|
||||
build:
|
||||
@@ -267,6 +299,8 @@ services:
|
||||
- "8080:80"
|
||||
networks:
|
||||
- goa-gel-network
|
||||
profiles:
|
||||
- docs # Only starts with: docker-compose --profile docs up
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost/"]
|
||||
interval: 30s
|
||||
|
||||
408
fixes-prompt.md
408
fixes-prompt.md
@@ -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.**
|
||||
451
frontend-plan.md
451
frontend-plan.md
@@ -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)
|
||||
@@ -1,3 +1,9 @@
|
||||
# ==============================================================================
|
||||
# Goa GEL Frontend - Multi-stage Docker Build
|
||||
# ==============================================================================
|
||||
# Supports runtime configuration via environment variables
|
||||
# ==============================================================================
|
||||
|
||||
# Stage 1: Build Angular application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
@@ -21,9 +27,13 @@ FROM nginx:alpine
|
||||
# Copy custom nginx configuration
|
||||
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 runtime config script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
@@ -31,5 +41,6 @@ EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
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;"]
|
||||
|
||||
34
frontend/docker-entrypoint.sh
Normal file
34
frontend/docker-entrypoint.sh
Normal 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
3
frontend/e2e/CLAUDE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
@@ -62,7 +62,7 @@ test.describe('Authentication', () => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// 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');
|
||||
|
||||
// Submit
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test';
|
||||
// Helper functions
|
||||
async function loginAsCitizen(page: Page) {
|
||||
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.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test';
|
||||
// Helper function to login as citizen
|
||||
async function loginAsCitizen(page: Page) {
|
||||
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.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
|
||||
679
frontend/package-lock.json
generated
679
frontend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@angular/material": "^21.1.3",
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -2242,6 +2243,176 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"make-dir": "^3.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"nopt": "^5.0.0",
|
||||
"npmlog": "^5.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"tar": "^6.1.11"
|
||||
},
|
||||
"bin": {
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minizlib": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minizlib/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"abbrev": "1"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^2.0.0",
|
||||
"fs-minipass": "^2.0.0",
|
||||
"minipass": "^5.0.0",
|
||||
"minizlib": "^2.1.1",
|
||||
"mkdirp": "^1.0.3",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
||||
@@ -4669,6 +4840,28 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/are-we-there-yet": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
@@ -4755,6 +4948,13 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -4883,6 +5083,17 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -5260,6 +5471,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
@@ -5297,6 +5518,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -5495,6 +5723,13 @@
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/console.table": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/console.table/-/console.table-0.10.0.tgz",
|
||||
@@ -5713,7 +5948,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -5772,6 +6007,13 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5786,7 +6028,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -5926,7 +6167,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -5937,7 +6177,6 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -6591,6 +6830,13 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -6616,6 +6862,90 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
|
||||
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"aproba": "^1.0.3 || ^2.0.0",
|
||||
"color-support": "^1.1.2",
|
||||
"console-control-strings": "^1.0.0",
|
||||
"has-unicode": "^2.0.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"signal-exit": "^3.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wide-align": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/gauge/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/gauge/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -6800,6 +7130,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -7017,11 +7354,23 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
@@ -7873,6 +8222,32 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"semver": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/make-fetch-happen": {
|
||||
"version": "15.0.3",
|
||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz",
|
||||
@@ -8182,6 +8557,19 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -8196,7 +8584,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
@@ -8255,6 +8643,13 @@
|
||||
"thenify-all": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
|
||||
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -8306,7 +8701,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
@@ -8327,21 +8722,21 @@
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
@@ -8550,6 +8945,20 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"are-we-there-yet": "^2.0.0",
|
||||
"console-control-strings": "^1.1.0",
|
||||
"gauge": "^3.0.0",
|
||||
"set-blocking": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -8567,7 +8976,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8624,7 +9033,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
@@ -8832,6 +9241,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -8887,6 +9306,16 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/path2d-polyfill": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
|
||||
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -8894,6 +9323,73 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "4.0.379",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.0.379.tgz",
|
||||
"integrity": "sha512-6H0Gv1nna+wmrr3CakaKlZ4rbrL8hvGIFAgg4YcoFuGC0HC4B2DVjXEGTFjJEjLlf8nYi3C3/MYRcM5bNx0elA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvas": "^2.11.2",
|
||||
"path2d-polyfill": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/canvas": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
|
||||
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "^1.0.0",
|
||||
"nan": "^2.17.0",
|
||||
"simple-get": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/decompress-response": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
|
||||
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/mimic-response": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
|
||||
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/simple-get": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
|
||||
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^4.2.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -9336,7 +9832,7 @@
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
@@ -9454,6 +9950,58 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-beta.58",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.58.tgz",
|
||||
@@ -9595,7 +10143,7 @@
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9616,7 +10164,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
@@ -9687,7 +10235,7 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -9743,6 +10291,13 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -9900,6 +10455,27 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
@@ -10089,7 +10665,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
@@ -10702,7 +11278,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
@@ -11001,6 +11577,71 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/wide-align/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
@@ -11091,7 +11732,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user