feat: Runtime configuration and Docker deployment improvements

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

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

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

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

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

View File

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

View File

@@ -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
View File

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

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

@@ -1,7 +1,10 @@
#!/bin/bash
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
View File

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

View File

@@ -140,6 +140,7 @@ async function deployPlaceholderContract(wallet, name) {
/**
* Update .env file with generated values
* 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
View 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"

View File

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

View File

@@ -39,7 +39,15 @@ import { UsersModule } from './modules/users/users.module';
// Configuration
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 [{
ttl: isDevelopment ? 1000 : configService.get<number>('RATE_LIMIT_TTL', 60) * 1000,
limit: isDevelopment ? 10000 : configService.get<number>('RATE_LIMIT_GLOBAL', 100),
}];
return [
{
ttl: isDevelopment ? 1000 : configService.get<number>('RATE_LIMIT_TTL', 60) * 1000,
limit: isDevelopment ? 10000 : configService.get<number>('RATE_LIMIT_GLOBAL', 100),
},
];
},
}),

View File

@@ -8,12 +8,7 @@ export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 100;
export const MAX_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',

View File

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

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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';
@@ -31,7 +26,7 @@ export class ApiKeyGuard implements CanActivate {
// Note: Actual validation is done in AuthService
// This guard just ensures the headers are present
// The AuthModule middleware validates the API key
return true;
}
}

View File

@@ -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({

View File

@@ -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';
@@ -16,7 +11,7 @@ export class CorrelationIdInterceptor implements NestInterceptor {
const response = context.switchToHttp().getResponse<Response>();
const correlationId = (request.headers[CORRELATION_ID_HEADER] as string) || uuidv4();
request.headers[CORRELATION_ID_HEADER] = correlationId;
response.setHeader(CORRELATION_ID_HEADER, correlationId);

View File

@@ -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();

View File

@@ -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));
}
}

View File

@@ -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';

View File

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

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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 };
export { QueryBuilder };

View File

@@ -24,7 +24,7 @@ export class RequestNumberUtil {
} | null {
const match = requestNumber.match(/^([A-Z]+)-(\d{4})-(\d+)$/);
if (!match) return null;
return {
prefix: match[1],
year: parseInt(match[2], 10),

View File

@@ -6,14 +6,14 @@ export const appConfigValidationSchema = Joi.object({
PORT: Joi.number().default(3001),
API_VERSION: Joi.string().default('v1'),
API_PREFIX: Joi.string().default('api'),
DATABASE_HOST: Joi.string().required(),
DATABASE_PORT: Joi.number().default(5432),
DATABASE_NAME: Joi.string().required(),
DATABASE_USER: Joi.string().required(),
DATABASE_PASSWORD: Joi.string().required(),
DATABASE_SSL: Joi.boolean().default(false),
BESU_RPC_URL: Joi.string().uri().required(),
BESU_CHAIN_ID: Joi.number().required(),
CONTRACT_ADDRESS_LICENSE_NFT: Joi.string().allow('').default(''),
@@ -21,30 +21,30 @@ export const appConfigValidationSchema = Joi.object({
CONTRACT_ADDRESS_DEPARTMENT_REGISTRY: Joi.string().allow('').default(''),
CONTRACT_ADDRESS_WORKFLOW_REGISTRY: Joi.string().allow('').default(''),
PLATFORM_WALLET_PRIVATE_KEY: Joi.string().allow('').default(''),
MINIO_ENDPOINT: Joi.string().required(),
MINIO_PORT: Joi.number().default(9000),
MINIO_ACCESS_KEY: Joi.string().required(),
MINIO_SECRET_KEY: Joi.string().required(),
MINIO_BUCKET_DOCUMENTS: Joi.string().default('goa-gel-documents'),
MINIO_USE_SSL: Joi.boolean().default(false),
REDIS_HOST: Joi.string().default('localhost'),
REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().allow('').default(''),
JWT_SECRET: Joi.string().min(32).required(),
JWT_EXPIRATION: Joi.string().default('1d'),
API_KEY_SALT_ROUNDS: Joi.number().default(10),
MAX_FILE_SIZE: Joi.number().default(10485760),
ALLOWED_MIME_TYPES: Joi.string().default('application/pdf,image/jpeg,image/png'),
RATE_LIMIT_GLOBAL: Joi.number().default(100),
RATE_LIMIT_API_KEY: Joi.number().default(1000),
LOG_LEVEL: Joi.string().valid('error', 'warn', 'info', 'debug').default('info'),
CORS_ORIGIN: Joi.string().default('http://localhost:3000'),
SWAGGER_ENABLED: Joi.boolean().default(true),
});

View File

@@ -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 {

View File

@@ -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,
@@ -43,7 +41,7 @@ export const KNEX_CONNECTION = 'KNEX_CONNECTION';
exports: [KNEX_CONNECTION, ModelsModule],
})
export class DatabaseModule implements OnModuleDestroy {
constructor(@Inject(KNEX_CONNECTION) private readonly knex: Knex.Knex) { }
constructor(@Inject(KNEX_CONNECTION) private readonly knex: Knex.Knex) {}
async onModuleDestroy(): Promise<void> {
if (this.knex) {

View File

@@ -78,4 +78,4 @@ const knexConfig: { [key: string]: Knex.Config } = {
},
};
export default knexConfig;
export default knexConfig;

View File

@@ -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();

View File

@@ -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');

View File

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

View File

@@ -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' },

View File

@@ -60,7 +60,7 @@ export class LicenseRequest extends BaseModel {
const { Document } = require('./document.model');
const { Approval } = require('./approval.model');
const { WorkflowState } = require('./workflow-state.model');
return {
applicant: {
relation: Model.BelongsToOneRelation,

View File

@@ -19,4 +19,4 @@ export class WorkflowState extends BaseModel {
},
},
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ import { LoggingInterceptor, CorrelationIdInterceptor } from './common/intercept
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
@@ -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,12 +67,13 @@ 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
? Object.values(firstError.constraints)[0]
: 'Validation failed';
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) {
@@ -124,7 +132,7 @@ async function bootstrap(): Promise<void> {
displayRequestDuration: true,
},
});
logger.log(`Swagger documentation available at http://localhost:${port}/api/docs`);
}
@@ -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);

View File

@@ -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')

View File

@@ -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,22 +309,24 @@ export class AdminService {
uploadedAt: doc.createdAt,
uploadedBy: doc.uploadedByUser?.name || 'Unknown',
currentVersion: doc.version || 1,
versions: doc.versions?.map((v: any) => ({
id: v.id,
version: v.version,
fileHash: v.fileHash,
uploadedAt: v.createdAt,
uploadedBy: v.uploadedByUser?.name || 'Unknown',
changes: v.changes,
})) || [],
departmentReviews: doc.departmentReviews?.map((review: any) => ({
departmentCode: review.department?.code || 'UNKNOWN',
departmentName: review.department?.name || 'Unknown Department',
reviewedAt: review.createdAt,
reviewedBy: review.reviewedByUser?.name || 'Unknown',
status: review.status,
comments: review.comments,
})) || [],
versions:
doc.versions?.map((v: any) => ({
id: v.id,
version: v.version,
fileHash: v.fileHash,
uploadedAt: v.createdAt,
uploadedBy: v.uploadedByUser?.name || 'Unknown',
changes: v.changes,
})) || [],
departmentReviews:
doc.departmentReviews?.map((review: any) => ({
departmentCode: review.department?.code || 'UNKNOWN',
departmentName: review.department?.name || 'Unknown Department',
reviewedAt: review.createdAt,
reviewedBy: review.reviewedByUser?.name || 'Unknown',
status: review.status,
comments: review.comments,
})) || [],
metadata: {
mimeType: doc.mimeType,
width: doc.width,
@@ -333,4 +335,41 @@ export class AdminService {
},
}));
}
async getBlockchainBlocks(limit: number = 5) {
this.logger.debug(`Fetching ${limit} recent blockchain blocks`);
try {
const rpcUrl = this.configService.get<string>('BESU_RPC_URL') || 'http://besu-node-1:8545';
const provider = new ethers.JsonRpcProvider(rpcUrl);
const latestBlockNumber = await provider.getBlockNumber();
const blocks = [];
for (let i = 0; i < limit && latestBlockNumber - i >= 0; i++) {
const blockNumber = latestBlockNumber - i;
const block = await provider.getBlock(blockNumber);
if (block) {
blocks.push({
blockNumber: block.number,
hash: block.hash,
parentHash: block.parentHash,
timestamp: new Date(block.timestamp * 1000).toISOString(),
transactionCount: block.transactions.length,
gasUsed: Number(block.gasUsed),
gasLimit: Number(block.gasLimit),
miner: block.miner,
nonce: block.nonce,
});
}
}
return { data: blocks };
} catch (error) {
this.logger.error('Failed to fetch blockchain blocks', error);
// Return empty array on error - frontend will use mock data
return { data: [] };
}
}
}

View File

@@ -9,13 +9,7 @@ import {
UseGuards,
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);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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')

View File

@@ -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,10 +202,9 @@ export class ApprovalsService {
// If no next stage, mark request as approved
if (!nextStageCreated) {
await this.requestsRepository.query()
.patchAndFetchById(requestId, {
status: RequestStatus.APPROVED,
});
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,10 +414,9 @@ export class ApprovalsService {
}
// Update request status to REJECTED
await this.requestsRepository.query()
.patchAndFetchById(requestId, {
status: RequestStatus.REJECTED,
});
await this.requestsRepository.query().patchAndFetchById(requestId, {
status: RequestStatus.REJECTED,
});
return this.mapToResponseDto(saved);
}
@@ -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')
@@ -655,7 +686,7 @@ export class ApprovalsService {
* Hash remarks for integrity verification
*/
hashRemarks(remarks: string): string {
if(!remarks) return null;
if (!remarks) return null;
return crypto.createHash('sha256').update(remarks).digest('hex');
}
@@ -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,

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -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 ||
(log.action === 'REQUEST_CANCELLED' ? (log.newValue as any)?.reason : undefined),
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),

View File

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

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -12,11 +12,11 @@ export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
jwtFromRequest: (req: Request) => {
const apiKey = req.headers[API_KEY_HEADER] as string;
const departmentCode = req.headers[DEPARTMENT_CODE_HEADER] as string;
if (!apiKey || !departmentCode) {
return null;
}
// Return a dummy token - actual validation happens in validate()
return `${apiKey}:${departmentCode}`;
},
@@ -26,13 +26,13 @@ export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
async validate(token: string): Promise<{ departmentId: string; departmentCode: string }> {
const [apiKey, departmentCode] = token.split(':');
if (!apiKey || !departmentCode) {
throw new UnauthorizedException('API key and department code are required');
}
const result = await this.authService.validateDepartmentApiKey(apiKey, departmentCode);
return {
departmentId: result.department.id,
departmentCode: result.department.code,

View File

@@ -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);

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;
}
}

View File

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

View File

@@ -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` };
}
}

View File

@@ -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}`,
);

View File

@@ -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 {

View File

@@ -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;
}
}
}

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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,38 +351,80 @@ 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;
await document.$query().patch({
downloadCount: currentCount + 1,
lastDownloadedAt: new Date().toISOString(),
});
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) {
await this.auditService.record({
entityType: 'REQUEST',
entityId: document.requestId,
action: 'DOCUMENT_DOWNLOADED',
actorType: 'USER',
actorId: userId,
newValue: {
documentId: documentId,
filename: document.originalFilename,
version: targetVersion.version,
performedBy: userId,
reason: `Document ${documentId} downloaded: ${document.originalFilename}`,
},
});
try {
await this.auditService.record({
entityType: 'REQUEST',
entityId: document.requestId,
action: 'DOCUMENT_DOWNLOADED',
actorType: 'USER',
actorId: userId,
newValue: {
documentId: documentId,
filename: document.originalFilename,
version: targetVersion.version,
performedBy: userId,
reason: `Document ${documentId} downloaded: ${document.originalFilename}`,
},
});
} catch (auditError: any) {
// Log but don't fail the download for audit errors
this.logger.warn(
`Failed to record audit log for document download ${documentId}: ${auditError.message}`,
);
}
}
return {
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(', ')}`,
);
}
}

View File

@@ -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',
})

View File

@@ -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 {

View File

@@ -1,4 +1,9 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import {
Injectable,
Logger,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * 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;
}
}
}

View File

@@ -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';

View File

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

View File

@@ -82,7 +82,7 @@ export class RequestsController {
this.logger.debug(`[${correlationId}] Creating new request for user: ${user.email}`);
// 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: {
total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(total / query.limit),
},
data: data.map(r => this.mapToResponseDto(r)),
total,
page: query.page,
limit: 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,44 +585,50 @@ export class RequestsController {
formData: metadata,
blockchainTxHash: request.blockchainTxHash,
tokenId: request.tokenId,
documents: (request.documents as any)?.map((d: any) => ({
id: d.id,
docType: d.docType,
originalFilename: d.originalFilename,
currentVersion: d.currentVersion,
currentHash: d.currentHash,
minioBucket: d.minioBucket,
isActive: d.isActive,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
})) || [],
approvals: (request.approvals as any)?.map((a: any) => ({
id: a.id,
departmentId: a.departmentId,
status: a.status,
remarks: a.remarks,
reviewedDocuments: a.reviewedDocuments,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
invalidatedAt: a.invalidatedAt,
invalidationReason: a.invalidationReason,
})) || [],
documents:
(request.documents as any)?.map((d: any) => ({
id: d.id,
docType: d.docType,
originalFilename: d.originalFilename,
currentVersion: d.currentVersion,
currentHash: d.currentHash,
minioBucket: d.minioBucket,
isActive: d.isActive,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
})) || [],
approvals:
(request.approvals as any)?.map((a: any) => ({
id: a.id,
departmentId: a.departmentId,
status: a.status,
remarks: a.remarks,
reviewedDocuments: a.reviewedDocuments,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
invalidatedAt: a.invalidatedAt,
invalidationReason: a.invalidationReason,
})) || [],
createdAt: request.createdAt,
updatedAt: request.updatedAt,
submittedAt: request.submittedAt,
approvedAt: request.approvedAt,
workflow: workflow ? {
id: workflow.id,
code: workflow.workflowType,
name: workflow.name,
steps: workflow.definition?.steps || [],
} : undefined,
applicant: applicant ? {
id: applicant.id,
email: applicant.email,
name: applicant.name,
walletAddress: applicant.walletAddress || '',
} : undefined,
workflow: workflow
? {
id: workflow.id,
code: workflow.workflowType,
name: workflow.name,
steps: workflow.definition?.steps || [],
}
: undefined,
applicant: applicant
? {
id: applicant.id,
email: applicant.email,
name: applicant.name,
walletAddress: applicant.walletAddress || '',
}
: undefined,
};
return plainToInstance(RequestDetailResponseDto, result, { excludeExtraneousValues: false });

View File

@@ -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 ||
request.status === LicenseRequestStatus.IN_REVIEW;
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 = {
@@ -390,13 +426,13 @@ export class RequestsService {
const request = await this.findById(id);
const metadataPatch = {};
if(dto.businessName !== undefined) {
if (dto.businessName !== undefined) {
metadataPatch['businessName'] = dto.businessName;
}
if(dto.description !== undefined) {
if (dto.description !== undefined) {
metadataPatch['description'] = dto.description;
}
if(dto.metadata !== undefined) {
if (dto.metadata !== undefined) {
Object.assign(metadataPatch, dto.metadata);
}
@@ -453,7 +489,8 @@ export class RequestsService {
});
}
const approvals = await this.approvalRepository.query()
const approvals = await this.approvalRepository
.query()
.where({ requestId: request.id })
.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 };

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,9 +82,13 @@ export class WorkflowExecutorService {
* Get workflow state
*/
async getWorkflowState(requestId: string): Promise<WorkflowState | null> {
const stateEntity = await (this.workflowStateRepository.constructor as typeof WorkflowStateModel).query().findOne({
requestId,
});
const stateEntity = await (
this.workflowStateRepository.constructor as typeof WorkflowStateModel
)
.query()
.findOne({
requestId,
});
return stateEntity ? stateEntity.state : null;
}
@@ -99,9 +97,13 @@ export class WorkflowExecutorService {
* Save workflow state
*/
async saveWorkflowState(state: WorkflowState): Promise<void> {
const stateEntity = await (this.workflowStateRepository.constructor as typeof WorkflowStateModel).query().findOne({
requestId: state.requestId,
});
const stateEntity = await (
this.workflowStateRepository.constructor as typeof WorkflowStateModel
)
.query()
.findOne({
requestId: state.requestId,
});
if (stateEntity) {
await stateEntity.$query().patch({ state });
@@ -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,18 +312,15 @@ export class WorkflowExecutorService {
};
}
const affectedDepartments =
await this.approvalsService.invalidateApprovalsByDocument(
requestId,
documentId,
'Document was updated',
);
const affectedDepartments = await this.approvalsService.invalidateApprovalsByDocument(
requestId,
documentId,
'Document was updated',
);
// 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);

View File

@@ -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,
})),

View File

@@ -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')

View File

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