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:
@@ -50,14 +50,21 @@ COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/package*.json ./
|
||||
|
||||
# Copy compiled database migrations and seeds from dist
|
||||
# Copy compiled database migrations and seeds from dist (only .js files, no .d.ts or .map)
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/dist/database/migrations ./src/database/migrations
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/dist/database/seeds ./src/database/seeds
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/dist/database/knexfile.js ./src/database/knexfile.js
|
||||
|
||||
# Copy initialization scripts
|
||||
# Remove TypeScript declaration files that can confuse Knex
|
||||
RUN find ./src/database -name "*.d.ts" -delete && \
|
||||
find ./src/database -name "*.js.map" -delete
|
||||
|
||||
# Copy initialization scripts (shell and JS)
|
||||
COPY --chown=nestjs:nodejs scripts ./scripts
|
||||
RUN chmod +x scripts/*.sh
|
||||
RUN chmod +x scripts/*.sh 2>/dev/null || true
|
||||
|
||||
# Create data directory for initialization flags
|
||||
RUN mkdir -p /app/data && chown nestjs:nodejs /app/data
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
"migrate:rollback": "npm run knex -- migrate:rollback",
|
||||
"migrate:status": "npm run knex -- migrate:status",
|
||||
"seed:make": "npm run knex -- seed:make",
|
||||
"seed:run": "npm run knex -- seed:run"
|
||||
"seed:run": "npm run knex -- seed:run",
|
||||
"db:reset": "bash scripts/db-reset.sh",
|
||||
"db:reset:force": "bash scripts/db-reset.sh --force",
|
||||
"db:seed:demo": "bash scripts/seed-demo-applications.sh",
|
||||
"db:health": "bash scripts/health-check.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bull": "10.0.1",
|
||||
|
||||
185
backend/scripts/db-reset.sh
Executable file
185
backend/scripts/db-reset.sh
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "========================================"
|
||||
echo " Goa-GEL Database Reset"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if running in Docker or locally
|
||||
if [ -z "$DATABASE_HOST" ]; then
|
||||
# Load from .env file if not in Docker
|
||||
if [ -f ".env" ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Default values for local development
|
||||
DATABASE_HOST=${DATABASE_HOST:-localhost}
|
||||
DATABASE_PORT=${DATABASE_PORT:-5432}
|
||||
DATABASE_NAME=${DATABASE_NAME:-goa_gel_platform}
|
||||
DATABASE_USER=${DATABASE_USER:-postgres}
|
||||
DATABASE_PASSWORD=${DATABASE_PASSWORD:-postgres}
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}WARNING: This will delete ALL data in the database!${NC}"
|
||||
echo ""
|
||||
echo "Database: $DATABASE_NAME @ $DATABASE_HOST:$DATABASE_PORT"
|
||||
echo ""
|
||||
|
||||
# Check for --force flag
|
||||
if [ "$1" != "--force" ] && [ "$1" != "-f" ]; then
|
||||
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[1/4] Connecting to PostgreSQL..."
|
||||
|
||||
# Test connection
|
||||
if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q' 2>/dev/null; then
|
||||
echo -e "${RED}ERROR: Cannot connect to PostgreSQL${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN} - Connected successfully${NC}"
|
||||
|
||||
echo ""
|
||||
echo "[2/4] Dropping all tables..."
|
||||
|
||||
# Drop all tables by using a transaction
|
||||
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" <<EOF
|
||||
-- Disable triggers
|
||||
SET session_replication_role = replica;
|
||||
|
||||
-- Drop all tables in dependency order
|
||||
DROP TABLE IF EXISTS application_logs CASCADE;
|
||||
DROP TABLE IF EXISTS blockchain_events CASCADE;
|
||||
DROP TABLE IF EXISTS blockchain_transactions CASCADE;
|
||||
DROP TABLE IF EXISTS audit_logs CASCADE;
|
||||
DROP TABLE IF EXISTS webhook_logs CASCADE;
|
||||
DROP TABLE IF EXISTS webhooks CASCADE;
|
||||
DROP TABLE IF EXISTS workflow_states CASCADE;
|
||||
DROP TABLE IF EXISTS approvals CASCADE;
|
||||
DROP TABLE IF EXISTS document_versions CASCADE;
|
||||
DROP TABLE IF EXISTS documents CASCADE;
|
||||
DROP TABLE IF EXISTS license_requests CASCADE;
|
||||
DROP TABLE IF EXISTS wallets CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
DROP TABLE IF EXISTS applicants CASCADE;
|
||||
DROP TABLE IF EXISTS workflows CASCADE;
|
||||
DROP TABLE IF EXISTS departments CASCADE;
|
||||
DROP TABLE IF EXISTS knex_migrations CASCADE;
|
||||
DROP TABLE IF EXISTS knex_migrations_lock CASCADE;
|
||||
|
||||
-- Re-enable triggers
|
||||
SET session_replication_role = DEFAULT;
|
||||
|
||||
-- Vacuum to reclaim space
|
||||
VACUUM;
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN} - All tables dropped${NC}"
|
||||
|
||||
echo ""
|
||||
echo "[3/4] Running migrations..."
|
||||
|
||||
# Determine the correct directory for knexfile
|
||||
if [ -f "/app/src/database/knexfile.js" ]; then
|
||||
# Docker environment
|
||||
cd /app/src/database
|
||||
elif [ -f "src/database/knexfile.ts" ]; then
|
||||
# Local development
|
||||
cd src/database
|
||||
else
|
||||
echo -e "${RED}ERROR: Cannot find knexfile${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run migrations
|
||||
node -e "
|
||||
const knex = require('knex');
|
||||
const path = require('path');
|
||||
|
||||
// Try to load compiled JS first, then TypeScript
|
||||
let config;
|
||||
try {
|
||||
config = require('./knexfile.js').default || require('./knexfile.js');
|
||||
} catch (e) {
|
||||
require('ts-node/register');
|
||||
config = require('./knexfile.ts').default || require('./knexfile.ts');
|
||||
}
|
||||
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const db = knex(config[env] || config.development);
|
||||
|
||||
db.migrate.latest()
|
||||
.then(([batchNo, migrations]) => {
|
||||
console.log(' - Applied ' + migrations.length + ' migration(s)');
|
||||
migrations.forEach(m => console.log(' * ' + m));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(' - Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => db.destroy());
|
||||
"
|
||||
|
||||
echo -e "${GREEN} - Migrations completed${NC}"
|
||||
|
||||
echo ""
|
||||
echo "[4/4] Running seeds..."
|
||||
|
||||
node -e "
|
||||
const knex = require('knex');
|
||||
|
||||
// Try to load compiled JS first, then TypeScript
|
||||
let config;
|
||||
try {
|
||||
config = require('./knexfile.js').default || require('./knexfile.js');
|
||||
} catch (e) {
|
||||
require('ts-node/register');
|
||||
config = require('./knexfile.ts').default || require('./knexfile.ts');
|
||||
}
|
||||
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const db = knex(config[env] || config.development);
|
||||
|
||||
db.seed.run()
|
||||
.then(() => {
|
||||
console.log(' - Seeds completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(' - Seed failed:', err.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => db.destroy());
|
||||
"
|
||||
|
||||
echo -e "${GREEN} - Seeds completed${NC}"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo -e "${GREEN} Database Reset Complete!${NC}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Demo Accounts:"
|
||||
echo " Admin: admin@goa.gov.in / Admin@123"
|
||||
echo " Fire Dept: fire@goa.gov.in / Fire@123"
|
||||
echo " Tourism: tourism@goa.gov.in / Tourism@123"
|
||||
echo " Municipality: municipality@goa.gov.in / Municipality@123"
|
||||
echo " Citizen 1: citizen@example.com / Citizen@123"
|
||||
echo " Citizen 2: citizen2@example.com / Citizen@123"
|
||||
echo ""
|
||||
184
backend/scripts/docker-entrypoint.sh
Normal file → Executable file
184
backend/scripts/docker-entrypoint.sh
Normal file → Executable file
@@ -1,7 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting Goa-GEL Backend Initialization..."
|
||||
echo "========================================"
|
||||
echo " Goa-GEL Backend Initialization"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Function to check if this is first boot
|
||||
is_first_boot() {
|
||||
@@ -12,34 +15,191 @@ is_first_boot() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if database has data
|
||||
db_has_data() {
|
||||
local count
|
||||
count=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "0")
|
||||
if [ "$count" -gt "0" ]; then
|
||||
return 0 # true - has data
|
||||
else
|
||||
return 1 # false - empty
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if migrations table exists
|
||||
migrations_table_exists() {
|
||||
local exists
|
||||
exists=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'knex_migrations');" 2>/dev/null || echo "f")
|
||||
if [ "$exists" = "t" ]; then
|
||||
return 0 # true
|
||||
else
|
||||
return 1 # false
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p /app/data
|
||||
|
||||
# Ensure .env file exists
|
||||
touch /app/.env
|
||||
|
||||
# 1. Wait for and initialize database
|
||||
echo "📊 Step 1: Database initialization..."
|
||||
chmod +x /app/scripts/init-db.sh
|
||||
/app/scripts/init-db.sh
|
||||
# ========================================
|
||||
# Step 1: Wait for PostgreSQL
|
||||
# ========================================
|
||||
echo "[1/4] Waiting for PostgreSQL to be ready..."
|
||||
retries=30
|
||||
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q' 2>/dev/null; do
|
||||
retries=$((retries - 1))
|
||||
if [ $retries -le 0 ]; then
|
||||
echo "ERROR: PostgreSQL is not available after 30 retries"
|
||||
exit 1
|
||||
fi
|
||||
echo " - PostgreSQL is unavailable - sleeping (${retries} retries left)"
|
||||
sleep 2
|
||||
done
|
||||
echo " - PostgreSQL is up and accepting connections"
|
||||
|
||||
# ========================================
|
||||
# Step 2: Run Database Migrations
|
||||
# ========================================
|
||||
echo ""
|
||||
echo "[2/4] Running database migrations..."
|
||||
|
||||
# Change to database directory for Knex
|
||||
cd /app/src/database
|
||||
|
||||
# Run migrations using the compiled knexfile
|
||||
if node -e "
|
||||
const knex = require('knex');
|
||||
const config = require('./knexfile.js').default || require('./knexfile.js');
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const db = knex(config[env]);
|
||||
|
||||
db.migrate.latest()
|
||||
.then(([batchNo, migrations]) => {
|
||||
if (migrations.length === 0) {
|
||||
console.log(' - All migrations already applied');
|
||||
} else {
|
||||
console.log(' - Applied ' + migrations.length + ' migration(s)');
|
||||
migrations.forEach(m => console.log(' * ' + m));
|
||||
}
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(' - Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => db.destroy());
|
||||
"; then
|
||||
echo " - Migrations completed successfully"
|
||||
else
|
||||
echo "ERROR: Migration failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# Step 3: Run Database Seeds (if needed)
|
||||
# ========================================
|
||||
echo ""
|
||||
echo "[3/4] Checking if database seeding is needed..."
|
||||
|
||||
cd /app/src/database
|
||||
|
||||
# Check if we should run seeds
|
||||
SHOULD_SEED="false"
|
||||
|
||||
if ! db_has_data; then
|
||||
echo " - Database is empty, seeding required"
|
||||
SHOULD_SEED="true"
|
||||
elif [ "$FORCE_RESEED" = "true" ]; then
|
||||
echo " - FORCE_RESEED is set, reseeding database"
|
||||
SHOULD_SEED="true"
|
||||
else
|
||||
echo " - Database already has data, skipping seed"
|
||||
fi
|
||||
|
||||
if [ "$SHOULD_SEED" = "true" ]; then
|
||||
echo " - Running database seeds..."
|
||||
if node -e "
|
||||
const knex = require('knex');
|
||||
const config = require('./knexfile.js').default || require('./knexfile.js');
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const db = knex(config[env]);
|
||||
|
||||
db.seed.run()
|
||||
.then(() => {
|
||||
console.log(' - Seeds completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(' - Seed failed:', err.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => db.destroy());
|
||||
"; then
|
||||
echo " - Database seeded successfully"
|
||||
else
|
||||
echo "ERROR: Seeding failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# Step 4: Initialize Blockchain (if needed)
|
||||
# ========================================
|
||||
echo ""
|
||||
echo "[4/4] Checking blockchain initialization..."
|
||||
|
||||
cd /app
|
||||
|
||||
# 2. Initialize blockchain (only on first boot or if not configured)
|
||||
if is_first_boot || [ -z "$CONTRACT_ADDRESS_LICENSE_NFT" ] || [ "$CONTRACT_ADDRESS_LICENSE_NFT" = "0x0000000000000000000000000000000000000000" ]; then
|
||||
echo "🔗 Step 2: Blockchain initialization..."
|
||||
echo " - Blockchain not initialized, deploying contracts..."
|
||||
node /app/scripts/init-blockchain.js
|
||||
|
||||
# Mark as initialized
|
||||
touch /app/data/.initialized
|
||||
echo "✅ Blockchain initialization complete!"
|
||||
echo " - Blockchain initialization complete"
|
||||
|
||||
# Reload environment variables
|
||||
if [ -f "/app/.env" ]; then
|
||||
export $(grep -v '^#' /app/.env | xargs)
|
||||
set -a
|
||||
source /app/.env
|
||||
set +a
|
||||
fi
|
||||
else
|
||||
echo "⏭️ Step 2: Blockchain already initialized"
|
||||
echo " - Blockchain already initialized, skipping"
|
||||
fi
|
||||
|
||||
# 3. Start the application
|
||||
echo "🎯 Step 3: Starting NestJS application..."
|
||||
# ========================================
|
||||
# Final Health Check
|
||||
# ========================================
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Performing Health Checks"
|
||||
echo "========================================"
|
||||
|
||||
# Verify database has expected data
|
||||
USER_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "0")
|
||||
DEPT_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM departments;" 2>/dev/null || echo "0")
|
||||
WORKFLOW_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM workflows;" 2>/dev/null || echo "0")
|
||||
|
||||
echo " - Users: $USER_COUNT"
|
||||
echo " - Departments: $DEPT_COUNT"
|
||||
echo " - Workflows: $WORKFLOW_COUNT"
|
||||
|
||||
if [ "$USER_COUNT" -eq "0" ] || [ "$DEPT_COUNT" -eq "0" ]; then
|
||||
echo ""
|
||||
echo "WARNING: Database appears to be empty!"
|
||||
echo "You may need to run: docker compose exec api npm run db:reset"
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# Start Application
|
||||
# ========================================
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Starting NestJS Application"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
exec npm run start:prod
|
||||
|
||||
20
backend/scripts/health-check.sh
Executable file
20
backend/scripts/health-check.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Health check script for Docker container
|
||||
# Returns exit code 0 if healthy, 1 if unhealthy
|
||||
|
||||
# Check if API is responding
|
||||
if ! wget --spider -q http://localhost:3001/api/v1/health 2>/dev/null; then
|
||||
echo "API health endpoint not responding"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check database connection and data
|
||||
USER_COUNT=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$USER_COUNT" -eq "0" ]; then
|
||||
echo "Database has no users"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Health check passed: API responding, $USER_COUNT users in database"
|
||||
exit 0
|
||||
@@ -140,6 +140,7 @@ async function deployPlaceholderContract(wallet, name) {
|
||||
|
||||
/**
|
||||
* Update .env file with generated values
|
||||
* Values containing spaces are quoted for shell compatibility
|
||||
*/
|
||||
function updateEnvFile(values) {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
@@ -151,11 +152,13 @@ function updateEnvFile(values) {
|
||||
|
||||
// Update or add each value
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
// Quote values that contain spaces (like mnemonic phrases)
|
||||
const formattedValue = value.includes(' ') ? `"${value}"` : value;
|
||||
const regex = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (regex.test(envContent)) {
|
||||
envContent = envContent.replace(regex, `${key}=${value}`);
|
||||
envContent = envContent.replace(regex, `${key}=${formattedValue}`);
|
||||
} else {
|
||||
envContent += `\n${key}=${value}`;
|
||||
envContent += `\n${key}=${formattedValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
backend/scripts/init-db.sh
Normal file → Executable file
32
backend/scripts/init-db.sh
Normal file → Executable file
@@ -1,30 +1,16 @@
|
||||
#!/bin/bash
|
||||
# This script is now deprecated - database initialization is handled by docker-entrypoint.sh
|
||||
# Kept for backward compatibility
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔄 Waiting for database to be ready..."
|
||||
echo "Database initialization is handled by docker-entrypoint.sh"
|
||||
echo "This script will only wait for PostgreSQL to be ready..."
|
||||
|
||||
# Wait for database to be ready
|
||||
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q' 2>/dev/null; do
|
||||
echo "⏳ PostgreSQL is unavailable - sleeping"
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "✅ PostgreSQL is up - checking if database is initialized..."
|
||||
|
||||
# Check if users table exists (indicating database is already set up)
|
||||
TABLE_EXISTS=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users');" 2>/dev/null || echo "f")
|
||||
|
||||
if [ "$TABLE_EXISTS" = "t" ]; then
|
||||
echo "✅ Database already initialized, skipping setup."
|
||||
else
|
||||
echo "📦 First time setup - creating tables and seeding data..."
|
||||
|
||||
# Run the SQL scripts directly
|
||||
echo "Creating tables..."
|
||||
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -f /app/scripts/create-all-tables.sql
|
||||
|
||||
echo "🌱 Seeding initial data..."
|
||||
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -f /app/scripts/seed-initial-data.sql
|
||||
|
||||
echo "✅ Database initialized successfully!"
|
||||
fi
|
||||
|
||||
echo "✅ Database ready!"
|
||||
echo "PostgreSQL is up and accepting connections"
|
||||
|
||||
214
backend/scripts/seed-demo-applications.sh
Executable file
214
backend/scripts/seed-demo-applications.sh
Executable file
@@ -0,0 +1,214 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "========================================"
|
||||
echo " Seed Demo License Applications"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Check if running in Docker or locally
|
||||
if [ -z "$DATABASE_HOST" ]; then
|
||||
# Load from .env file if not in Docker
|
||||
if [ -f ".env" ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
DATABASE_HOST=${DATABASE_HOST:-localhost}
|
||||
DATABASE_PORT=${DATABASE_PORT:-5432}
|
||||
DATABASE_NAME=${DATABASE_NAME:-goa_gel_platform}
|
||||
DATABASE_USER=${DATABASE_USER:-postgres}
|
||||
DATABASE_PASSWORD=${DATABASE_PASSWORD:-postgres}
|
||||
fi
|
||||
|
||||
echo "Adding demo license applications..."
|
||||
|
||||
# Get citizen ID
|
||||
CITIZEN_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM applicants WHERE digilocker_id = 'DL-GOA-CITIZEN-001' LIMIT 1;" 2>/dev/null)
|
||||
|
||||
if [ -z "$CITIZEN_ID" ] || [ "$CITIZEN_ID" = "" ]; then
|
||||
echo "ERROR: Could not find citizen applicant. Run seeds first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CITIZEN_ID=$(echo $CITIZEN_ID | xargs) # Trim whitespace
|
||||
|
||||
# Get workflow ID
|
||||
WORKFLOW_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM workflows WHERE workflow_type = 'RESORT_LICENSE' LIMIT 1;" 2>/dev/null)
|
||||
WORKFLOW_ID=$(echo $WORKFLOW_ID | xargs)
|
||||
|
||||
# Get department IDs
|
||||
FIRE_DEPT_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM departments WHERE code = 'FIRE_DEPT' LIMIT 1;" 2>/dev/null)
|
||||
FIRE_DEPT_ID=$(echo $FIRE_DEPT_ID | xargs)
|
||||
|
||||
TOURISM_DEPT_ID=$(PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -tAc "SELECT id FROM departments WHERE code = 'TOURISM_DEPT' LIMIT 1;" 2>/dev/null)
|
||||
TOURISM_DEPT_ID=$(echo $TOURISM_DEPT_ID | xargs)
|
||||
|
||||
echo " - Found applicant: $CITIZEN_ID"
|
||||
echo " - Found workflow: $WORKFLOW_ID"
|
||||
|
||||
# Insert demo license applications
|
||||
PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" <<EOF
|
||||
|
||||
-- Demo Application 1: Draft status
|
||||
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, created_at, updated_at)
|
||||
VALUES (
|
||||
'demo1111-1111-1111-1111-111111111111',
|
||||
'GOA-2024-RESORT-001',
|
||||
'$CITIZEN_ID',
|
||||
'RESORT_LICENSE',
|
||||
'$WORKFLOW_ID',
|
||||
'DRAFT',
|
||||
'{"businessName": "Paradise Beach Resort", "location": "Calangute Beach", "type": "Beach Resort", "capacity": 50}',
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP - INTERVAL '5 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '5 days'
|
||||
) ON CONFLICT (request_number) DO NOTHING;
|
||||
|
||||
-- Demo Application 2: Pending - waiting for Fire Dept
|
||||
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, created_at, updated_at)
|
||||
VALUES (
|
||||
'demo2222-2222-2222-2222-222222222222',
|
||||
'GOA-2024-RESORT-002',
|
||||
'$CITIZEN_ID',
|
||||
'RESORT_LICENSE',
|
||||
'$WORKFLOW_ID',
|
||||
'PENDING',
|
||||
'{"businessName": "Sunset View Resort", "location": "Baga Beach", "type": "Boutique Resort", "capacity": 30}',
|
||||
'stage_1_fire',
|
||||
CURRENT_TIMESTAMP - INTERVAL '3 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '4 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '3 days'
|
||||
) ON CONFLICT (request_number) DO NOTHING;
|
||||
|
||||
-- Create pending approval for Application 2
|
||||
INSERT INTO approvals (id, request_id, department_id, status, created_at, updated_at)
|
||||
SELECT
|
||||
'appr2222-2222-2222-2222-222222222222',
|
||||
'demo2222-2222-2222-2222-222222222222',
|
||||
'$FIRE_DEPT_ID',
|
||||
'PENDING',
|
||||
CURRENT_TIMESTAMP - INTERVAL '3 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '3 days'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM approvals WHERE id = 'appr2222-2222-2222-2222-222222222222'
|
||||
);
|
||||
|
||||
-- Create workflow state for Application 2
|
||||
INSERT INTO workflow_states (id, request_id, current_stage_id, completed_stages, pending_approvals, execution_log, stage_started_at, created_at, updated_at)
|
||||
SELECT
|
||||
'wfst2222-2222-2222-2222-222222222222',
|
||||
'demo2222-2222-2222-2222-222222222222',
|
||||
'stage_1_fire',
|
||||
'[]',
|
||||
'["$FIRE_DEPT_ID"]',
|
||||
'[{"action": "SUBMITTED", "timestamp": "' || (CURRENT_TIMESTAMP - INTERVAL '3 days')::text || '"}]',
|
||||
CURRENT_TIMESTAMP - INTERVAL '3 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '3 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '3 days'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM workflow_states WHERE request_id = 'demo2222-2222-2222-2222-222222222222'
|
||||
);
|
||||
|
||||
-- Demo Application 3: In Review - Fire approved, Tourism/Municipality pending
|
||||
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, created_at, updated_at)
|
||||
VALUES (
|
||||
'demo3333-3333-3333-3333-333333333333',
|
||||
'GOA-2024-RESORT-003',
|
||||
'$CITIZEN_ID',
|
||||
'RESORT_LICENSE',
|
||||
'$WORKFLOW_ID',
|
||||
'IN_REVIEW',
|
||||
'{"businessName": "Ocean Breeze Resort", "location": "Anjuna Beach", "type": "Eco Resort", "capacity": 25}',
|
||||
'stage_2_parallel',
|
||||
CURRENT_TIMESTAMP - INTERVAL '10 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '12 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '2 days'
|
||||
) ON CONFLICT (request_number) DO NOTHING;
|
||||
|
||||
-- Fire approval for Application 3 (completed)
|
||||
INSERT INTO approvals (id, request_id, department_id, status, remarks, created_at, updated_at)
|
||||
SELECT
|
||||
'appr3333-fire-3333-3333-333333333333',
|
||||
'demo3333-3333-3333-3333-333333333333',
|
||||
'$FIRE_DEPT_ID',
|
||||
'APPROVED',
|
||||
'Fire safety requirements met. All exits properly marked.',
|
||||
CURRENT_TIMESTAMP - INTERVAL '7 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '5 days'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM approvals WHERE id = 'appr3333-fire-3333-3333-333333333333'
|
||||
);
|
||||
|
||||
-- Tourism pending approval for Application 3
|
||||
INSERT INTO approvals (id, request_id, department_id, status, created_at, updated_at)
|
||||
SELECT
|
||||
'appr3333-tour-3333-3333-333333333333',
|
||||
'demo3333-3333-3333-3333-333333333333',
|
||||
'$TOURISM_DEPT_ID',
|
||||
'PENDING',
|
||||
CURRENT_TIMESTAMP - INTERVAL '5 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '5 days'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM approvals WHERE id = 'appr3333-tour-3333-3333-333333333333'
|
||||
);
|
||||
|
||||
-- Demo Application 4: Approved (completed all stages)
|
||||
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, approved_at, created_at, updated_at)
|
||||
VALUES (
|
||||
'demo4444-4444-4444-4444-444444444444',
|
||||
'GOA-2024-RESORT-004',
|
||||
'$CITIZEN_ID',
|
||||
'RESORT_LICENSE',
|
||||
'$WORKFLOW_ID',
|
||||
'APPROVED',
|
||||
'{"businessName": "Golden Sands Resort", "location": "Candolim Beach", "type": "Luxury Resort", "capacity": 100}',
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP - INTERVAL '30 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '5 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '35 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '5 days'
|
||||
) ON CONFLICT (request_number) DO NOTHING;
|
||||
|
||||
-- Demo Application 5: Rejected
|
||||
INSERT INTO license_requests (id, request_number, applicant_id, request_type, workflow_id, status, metadata, current_stage_id, submitted_at, created_at, updated_at)
|
||||
VALUES (
|
||||
'demo5555-5555-5555-5555-555555555555',
|
||||
'GOA-2024-RESORT-005',
|
||||
'$CITIZEN_ID',
|
||||
'RESORT_LICENSE',
|
||||
'$WORKFLOW_ID',
|
||||
'REJECTED',
|
||||
'{"businessName": "Beach Shack Resort", "location": "Morjim Beach", "type": "Beach Shack", "capacity": 15}',
|
||||
'stage_1_fire',
|
||||
CURRENT_TIMESTAMP - INTERVAL '20 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '25 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '15 days'
|
||||
) ON CONFLICT (request_number) DO NOTHING;
|
||||
|
||||
-- Rejected approval for Application 5
|
||||
INSERT INTO approvals (id, request_id, department_id, status, remarks, created_at, updated_at)
|
||||
SELECT
|
||||
'appr5555-fire-5555-5555-555555555555',
|
||||
'demo5555-5555-5555-5555-555555555555',
|
||||
'$FIRE_DEPT_ID',
|
||||
'REJECTED',
|
||||
'Fire safety requirements not met. Insufficient emergency exits.',
|
||||
CURRENT_TIMESTAMP - INTERVAL '18 days',
|
||||
CURRENT_TIMESTAMP - INTERVAL '15 days'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM approvals WHERE id = 'appr5555-fire-5555-5555-555555555555'
|
||||
);
|
||||
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "Demo applications created:"
|
||||
echo " - GOA-2024-RESORT-001: Draft"
|
||||
echo " - GOA-2024-RESORT-002: Pending (Fire Dept review)"
|
||||
echo " - GOA-2024-RESORT-003: In Review (Tourism/Municipality pending)"
|
||||
echo " - GOA-2024-RESORT-004: Approved"
|
||||
echo " - GOA-2024-RESORT-005: Rejected"
|
||||
echo ""
|
||||
echo "Done!"
|
||||
@@ -39,7 +39,15 @@ import { UsersModule } from './modules/users/users.module';
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, blockchainConfig, storageConfig, redisConfig, jwtConfig, minioConfig],
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
blockchainConfig,
|
||||
storageConfig,
|
||||
redisConfig,
|
||||
jwtConfig,
|
||||
minioConfig,
|
||||
],
|
||||
validationSchema: appConfigValidationSchema,
|
||||
validationOptions: {
|
||||
abortEarly: false,
|
||||
@@ -57,10 +65,12 @@ import { UsersModule } from './modules/users/users.module';
|
||||
const nodeEnv = configService.get<string>('NODE_ENV', 'development');
|
||||
const isDevelopment = nodeEnv === 'development' || nodeEnv === 'test';
|
||||
|
||||
return [{
|
||||
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),
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@ export const DEFAULT_PAGE_SIZE = 20;
|
||||
export const MAX_PAGE_SIZE = 100;
|
||||
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
export const ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/jpg',
|
||||
];
|
||||
export const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
|
||||
|
||||
export const REQUEST_NUMBER_PREFIX = {
|
||||
RESORT_LICENSE: 'RL',
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const CorrelationId = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ERROR_CODES } from '@common/constants/error-codes';
|
||||
|
||||
@@ -34,10 +28,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
let message = 'An unexpected error occurred';
|
||||
|
||||
if (exception instanceof Error) {
|
||||
this.logger.error(
|
||||
`Unhandled Exception: ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
this.logger.error(`Unhandled Exception: ${exception.message}`, exception.stack);
|
||||
|
||||
if (exception.message.includes('ECONNREFUSED')) {
|
||||
status = HttpStatus.SERVICE_UNAVAILABLE;
|
||||
|
||||
@@ -49,11 +49,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${message}`,
|
||||
exception.stack,
|
||||
correlationId,
|
||||
);
|
||||
this.logger.error(`Unhandled exception: ${message}`, exception.stack, correlationId);
|
||||
}
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { API_KEY_HEADER, DEPARTMENT_CODE_HEADER, ERROR_CODES } from '../constants';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '../enums';
|
||||
import { ERROR_CODES } from '../constants';
|
||||
@@ -34,7 +29,9 @@ export class RolesGuard implements CanActivate {
|
||||
});
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.role === role);
|
||||
// Map CITIZEN role to APPLICANT for backwards compatibility
|
||||
const normalizedUserRole = user.role === 'CITIZEN' ? UserRole.APPLICANT : user.role;
|
||||
const hasRole = requiredRoles.some(role => normalizedUserRole === role);
|
||||
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException({
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Request, Response } from 'express';
|
||||
@@ -17,7 +11,7 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const { method, url, ip } = request;
|
||||
const correlationId = request.headers['x-correlation-id'] as string || 'no-correlation-id';
|
||||
const correlationId = (request.headers['x-correlation-id'] as string) || 'no-correlation-id';
|
||||
const userAgent = request.get('user-agent') || '';
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import { timeout } from 'rxjs/operators';
|
||||
@Injectable()
|
||||
export class TimeoutInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(
|
||||
timeout(30000),
|
||||
);
|
||||
return next.handle().pipe(timeout(30000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
PipeTransform,
|
||||
Injectable,
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { ERROR_CODES } from '../constants';
|
||||
@@ -19,7 +14,7 @@ export class CustomValidationPipe implements PipeTransform {
|
||||
const errors = await validate(object);
|
||||
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((error) => {
|
||||
const messages = errors.map(error => {
|
||||
const constraints = error.constraints || {};
|
||||
return {
|
||||
field: error.property,
|
||||
|
||||
@@ -15,10 +15,7 @@ export async function generateApiKey(): Promise<{
|
||||
const apiKey = `goa_${crypto.randomBytes(16).toString('hex')}`;
|
||||
const apiSecret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const [apiKeyHash, apiSecretHash] = await Promise.all([
|
||||
hash(apiKey),
|
||||
hash(apiSecret),
|
||||
]);
|
||||
const [apiKeyHash, apiSecretHash] = await Promise.all([hash(apiKey), hash(apiSecret)]);
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
@@ -40,10 +37,7 @@ export class CryptoUtil {
|
||||
const iv = randomBytes(CryptoUtil.IV_LENGTH);
|
||||
|
||||
const cipher = createCipheriv(CryptoUtil.ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(data, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
|
||||
@@ -85,10 +85,7 @@ export class DateUtil {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
return format
|
||||
.replace('DD', day)
|
||||
.replace('MM', month)
|
||||
.replace('YYYY', year.toString());
|
||||
return format.replace('DD', day).replace('MM', month).replace('YYYY', year.toString());
|
||||
}
|
||||
|
||||
static parseISO(dateString: string): Date {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryBuilder } from 'objection';
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
results: T[];
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function paginate<T>(
|
||||
const l = limit > 0 ? limit : 10;
|
||||
|
||||
const { results, total } = await query.page(p, l);
|
||||
return { results, total };
|
||||
return { data: results, total };
|
||||
}
|
||||
|
||||
export { QueryBuilder };
|
||||
export { QueryBuilder };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ export default registerAs('minio', (): MinioConfig => {
|
||||
const secretKey = process.env.MINIO_SECRET_KEY || 'minioadmin_secret_change_this';
|
||||
|
||||
if (accessKey === 'minioadmin' || secretKey === 'minioadmin_secret_change_this') {
|
||||
console.warn('Warning: MinIO credentials are using default values. Change these in production!');
|
||||
console.warn(
|
||||
'Warning: MinIO credentials are using default values. Change these in production!',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,9 +22,7 @@ export const KNEX_CONNECTION = 'KNEX_CONNECTION';
|
||||
database: configService.get<string>('database.database'),
|
||||
user: configService.get<string>('database.username'),
|
||||
password: configService.get<string>('database.password'),
|
||||
ssl: configService.get<boolean>('database.ssl')
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
ssl: configService.get<boolean>('database.ssl') ? { rejectUnauthorized: false } : false,
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
@@ -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) {
|
||||
|
||||
@@ -78,4 +78,4 @@ const knexConfig: { [key: string]: Knex.Config } = {
|
||||
},
|
||||
};
|
||||
|
||||
export default knexConfig;
|
||||
export default knexConfig;
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
|
||||
|
||||
// Applicants table
|
||||
await knex.schema.createTable('applicants', (table) => {
|
||||
await knex.schema.createTable('applicants', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('digilocker_id', 255).notNullable().unique();
|
||||
table.string('name', 255).notNullable();
|
||||
@@ -21,7 +21,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Departments table
|
||||
await knex.schema.createTable('departments', (table) => {
|
||||
await knex.schema.createTable('departments', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('code', 50).notNullable().unique();
|
||||
table.string('name', 255).notNullable();
|
||||
@@ -39,7 +39,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Workflows table
|
||||
await knex.schema.createTable('workflows', (table) => {
|
||||
await knex.schema.createTable('workflows', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('workflow_type', 100).notNullable().unique();
|
||||
table.string('name', 255).notNullable();
|
||||
@@ -56,11 +56,16 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// License Requests table
|
||||
await knex.schema.createTable('license_requests', (table) => {
|
||||
await knex.schema.createTable('license_requests', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('request_number', 50).notNullable().unique();
|
||||
table.bigInteger('token_id');
|
||||
table.uuid('applicant_id').notNullable().references('id').inTable('applicants').onDelete('CASCADE');
|
||||
table
|
||||
.uuid('applicant_id')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('applicants')
|
||||
.onDelete('CASCADE');
|
||||
table.string('request_type', 100).notNullable();
|
||||
table.uuid('workflow_id').references('id').inTable('workflows').onDelete('SET NULL');
|
||||
table.string('status', 50).notNullable().defaultTo('DRAFT');
|
||||
@@ -81,9 +86,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Documents table
|
||||
await knex.schema.createTable('documents', (table) => {
|
||||
await knex.schema.createTable('documents', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.uuid('request_id').notNullable().references('id').inTable('license_requests').onDelete('CASCADE');
|
||||
table
|
||||
.uuid('request_id')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('license_requests')
|
||||
.onDelete('CASCADE');
|
||||
table.string('doc_type', 100).notNullable();
|
||||
table.string('original_filename', 255).notNullable();
|
||||
table.integer('current_version').notNullable().defaultTo(1);
|
||||
@@ -98,9 +108,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Document Versions table
|
||||
await knex.schema.createTable('document_versions', (table) => {
|
||||
await knex.schema.createTable('document_versions', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.uuid('document_id').notNullable().references('id').inTable('documents').onDelete('CASCADE');
|
||||
table
|
||||
.uuid('document_id')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('documents')
|
||||
.onDelete('CASCADE');
|
||||
table.integer('version').notNullable();
|
||||
table.string('hash', 66).notNullable();
|
||||
table.string('minio_path', 500).notNullable();
|
||||
@@ -115,10 +130,20 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Approvals table
|
||||
await knex.schema.createTable('approvals', (table) => {
|
||||
await knex.schema.createTable('approvals', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.uuid('request_id').notNullable().references('id').inTable('license_requests').onDelete('CASCADE');
|
||||
table.uuid('department_id').notNullable().references('id').inTable('departments').onDelete('CASCADE');
|
||||
table
|
||||
.uuid('request_id')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('license_requests')
|
||||
.onDelete('CASCADE');
|
||||
table
|
||||
.uuid('department_id')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('departments')
|
||||
.onDelete('CASCADE');
|
||||
table.string('status', 50).notNullable().defaultTo('PENDING');
|
||||
table.text('remarks');
|
||||
table.string('remarks_hash', 66);
|
||||
@@ -137,9 +162,15 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Workflow States table
|
||||
await knex.schema.createTable('workflow_states', (table) => {
|
||||
await knex.schema.createTable('workflow_states', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.uuid('request_id').notNullable().unique().references('id').inTable('license_requests').onDelete('CASCADE');
|
||||
table
|
||||
.uuid('request_id')
|
||||
.notNullable()
|
||||
.unique()
|
||||
.references('id')
|
||||
.inTable('license_requests')
|
||||
.onDelete('CASCADE');
|
||||
table.string('current_stage_id', 100).notNullable();
|
||||
table.jsonb('completed_stages').notNullable().defaultTo('[]');
|
||||
table.jsonb('pending_approvals').notNullable().defaultTo('[]');
|
||||
@@ -152,9 +183,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Webhooks table
|
||||
await knex.schema.createTable('webhooks', (table) => {
|
||||
await knex.schema.createTable('webhooks', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.uuid('department_id').notNullable().references('id').inTable('departments').onDelete('CASCADE');
|
||||
table
|
||||
.uuid('department_id')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('departments')
|
||||
.onDelete('CASCADE');
|
||||
table.string('url', 500).notNullable();
|
||||
table.jsonb('events').notNullable();
|
||||
table.string('secret_hash', 255).notNullable();
|
||||
@@ -166,7 +202,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Webhook Logs table
|
||||
await knex.schema.createTable('webhook_logs', (table) => {
|
||||
await knex.schema.createTable('webhook_logs', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.uuid('webhook_id').notNullable().references('id').inTable('webhooks').onDelete('CASCADE');
|
||||
table.string('event_type', 100).notNullable();
|
||||
@@ -185,7 +221,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Audit Logs table
|
||||
await knex.schema.createTable('audit_logs', (table) => {
|
||||
await knex.schema.createTable('audit_logs', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('entity_type', 50).notNullable();
|
||||
table.uuid('entity_id').notNullable();
|
||||
@@ -207,7 +243,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Blockchain Transactions table
|
||||
await knex.schema.createTable('blockchain_transactions', (table) => {
|
||||
await knex.schema.createTable('blockchain_transactions', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('tx_hash', 66).notNullable().unique();
|
||||
table.string('tx_type', 50).notNullable();
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Users table for email/password authentication
|
||||
await knex.schema.createTable('users', (table) => {
|
||||
await knex.schema.createTable('users', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('email', 255).notNullable().unique();
|
||||
table.string('password_hash', 255).notNullable();
|
||||
@@ -24,7 +24,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Wallets table for storing encrypted private keys
|
||||
await knex.schema.createTable('wallets', (table) => {
|
||||
await knex.schema.createTable('wallets', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('address', 42).notNullable().unique();
|
||||
table.text('encrypted_private_key').notNullable();
|
||||
@@ -39,7 +39,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Blockchain events table
|
||||
await knex.schema.createTable('blockchain_events', (table) => {
|
||||
await knex.schema.createTable('blockchain_events', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.string('tx_hash', 66).notNullable();
|
||||
table.string('event_name', 100).notNullable();
|
||||
@@ -62,7 +62,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Application logs table
|
||||
await knex.schema.createTable('application_logs', (table) => {
|
||||
await knex.schema.createTable('application_logs', table => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
|
||||
table.enum('level', ['DEBUG', 'INFO', 'WARN', 'ERROR']).notNullable();
|
||||
table.string('module', 100).notNullable();
|
||||
@@ -83,7 +83,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Add additional fields to departments table
|
||||
await knex.schema.alterTable('departments', (table) => {
|
||||
await knex.schema.alterTable('departments', table => {
|
||||
table.text('description');
|
||||
table.string('contact_email', 255);
|
||||
table.string('contact_phone', 20);
|
||||
@@ -93,7 +93,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
// Remove additional fields from departments
|
||||
await knex.schema.alterTable('departments', (table) => {
|
||||
await knex.schema.alterTable('departments', table => {
|
||||
table.dropColumn('description');
|
||||
table.dropColumn('contact_email');
|
||||
table.dropColumn('contact_phone');
|
||||
|
||||
@@ -2,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 {}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,4 +19,4 @@ export class WorkflowState extends BaseModel {
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -36,7 +36,8 @@ export class AdminController {
|
||||
@Get('stats')
|
||||
@ApiOperation({
|
||||
summary: 'Get platform statistics',
|
||||
description: 'Get overall platform statistics including request counts, user counts, and transaction data',
|
||||
description:
|
||||
'Get overall platform statistics including request counts, user counts, and transaction data',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Platform statistics' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@@ -49,7 +50,8 @@ export class AdminController {
|
||||
@Get('health')
|
||||
@ApiOperation({
|
||||
summary: 'Get system health',
|
||||
description: 'Get health status of all platform services (database, blockchain, storage, queue)',
|
||||
description:
|
||||
'Get health status of all platform services (database, blockchain, storage, queue)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'System health status' })
|
||||
async getHealth() {
|
||||
@@ -72,25 +74,51 @@ export class AdminController {
|
||||
return this.adminService.getRecentActivity(limit || 20);
|
||||
}
|
||||
|
||||
@Get('blockchain/blocks')
|
||||
@ApiOperation({
|
||||
summary: 'Get recent blockchain blocks',
|
||||
description: 'Get the most recent blocks from the blockchain',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Number of blocks to fetch (default: 5)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Recent blockchain blocks' })
|
||||
async getBlockchainBlocks(@Query('limit') limit?: number) {
|
||||
return this.adminService.getBlockchainBlocks(limit || 5);
|
||||
}
|
||||
|
||||
@Get('blockchain/transactions')
|
||||
@ApiOperation({
|
||||
summary: 'List blockchain transactions',
|
||||
description: 'Get paginated list of blockchain transactions with optional status filter',
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by transaction status (PENDING, CONFIRMED, FAILED)' })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 20)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'status',
|
||||
required: false,
|
||||
description: 'Filter by transaction status (PENDING, CONFIRMED, FAILED)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Paginated blockchain transactions' })
|
||||
async getBlockchainTransactions(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.adminService.getBlockchainTransactions(
|
||||
page || 1,
|
||||
limit || 20,
|
||||
status,
|
||||
);
|
||||
return this.adminService.getBlockchainTransactions(page || 1, limit || 20, status);
|
||||
}
|
||||
|
||||
@Post('departments')
|
||||
@@ -123,13 +151,20 @@ export class AdminController {
|
||||
summary: 'List all departments',
|
||||
description: 'Get list of all departments with pagination',
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 20)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'List of departments' })
|
||||
async getDepartments(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
async getDepartments(@Query('page') page?: number, @Query('limit') limit?: number) {
|
||||
return this.adminService.getDepartments(page || 1, limit || 20);
|
||||
}
|
||||
|
||||
@@ -203,8 +238,18 @@ export class AdminController {
|
||||
summary: 'List blockchain events',
|
||||
description: 'Get paginated list of blockchain events with optional filters',
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 20)',
|
||||
})
|
||||
@ApiQuery({ name: 'eventType', required: false, description: 'Filter by event type' })
|
||||
@ApiQuery({ name: 'contractAddress', required: false, description: 'Filter by contract address' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated blockchain events' })
|
||||
@@ -227,9 +272,23 @@ export class AdminController {
|
||||
summary: 'List application logs',
|
||||
description: 'Get paginated list of application logs with optional filters',
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 50)' })
|
||||
@ApiQuery({ name: 'level', required: false, description: 'Filter by log level (INFO, WARN, ERROR)' })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 50)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'level',
|
||||
required: false,
|
||||
description: 'Filter by log level (INFO, WARN, ERROR)',
|
||||
})
|
||||
@ApiQuery({ name: 'module', required: false, description: 'Filter by module name' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search in log messages' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated application logs' })
|
||||
@@ -240,13 +299,7 @@ export class AdminController {
|
||||
@Query('module') module?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.adminService.getApplicationLogs(
|
||||
page || 1,
|
||||
limit || 50,
|
||||
level,
|
||||
module,
|
||||
search,
|
||||
);
|
||||
return this.adminService.getApplicationLogs(page || 1, limit || 50, level, module, search);
|
||||
}
|
||||
|
||||
@Get('documents/:requestId')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ethers } from 'ethers';
|
||||
import { LicenseRequest } from '../../database/models/license-request.model';
|
||||
import { Applicant } from '../../database/models/applicant.model';
|
||||
import { Department } from '../../database/models/department.model';
|
||||
@@ -12,16 +13,24 @@ import { AuditLog } from '../../database/models/audit-log.model';
|
||||
import { DepartmentsService } from '../departments/departments.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
export interface StatusCount {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PlatformStats {
|
||||
totalRequests: number;
|
||||
requestsByStatus: Record<string, number>;
|
||||
totalApprovals: number;
|
||||
requestsByStatus: StatusCount[];
|
||||
totalApplicants: number;
|
||||
activeApplicants: number;
|
||||
totalDepartments: number;
|
||||
activeDepartments: number;
|
||||
totalDocuments: number;
|
||||
totalBlockchainTransactions: number;
|
||||
transactionsByStatus: Record<string, number>;
|
||||
transactionsByStatus: StatusCount[];
|
||||
averageProcessingTime: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
@@ -70,44 +79,43 @@ export class AdminService {
|
||||
transactionsByStatus,
|
||||
] = await Promise.all([
|
||||
this.requestModel.query().resultSize(),
|
||||
this.requestModel
|
||||
.query()
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status') as any,
|
||||
this.requestModel.query().select('status').count('* as count').groupBy('status') as any,
|
||||
this.applicantModel.query().resultSize(),
|
||||
this.applicantModel.query().where({ isActive: true }).resultSize(),
|
||||
this.applicantModel.query().where({ is_active: true }).resultSize(),
|
||||
this.departmentModel.query().resultSize(),
|
||||
this.departmentModel.query().where({ isActive: true }).resultSize(),
|
||||
this.departmentModel.query().where({ is_active: true }).resultSize(),
|
||||
this.documentModel.query().resultSize(),
|
||||
this.blockchainTxModel.query().resultSize(),
|
||||
this.blockchainTxModel
|
||||
.query()
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status') as any,
|
||||
this.blockchainTxModel.query().select('status').count('* as count').groupBy('status') as any,
|
||||
]);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
for (const row of requestsByStatus) {
|
||||
statusMap[(row as any).status] = parseInt((row as any).count, 10);
|
||||
}
|
||||
// Convert to array format expected by frontend
|
||||
const statusArray: StatusCount[] = requestsByStatus.map((row: any) => ({
|
||||
status: row.status,
|
||||
count: parseInt(row.count, 10),
|
||||
}));
|
||||
|
||||
const txStatusMap: Record<string, number> = {};
|
||||
for (const row of transactionsByStatus) {
|
||||
txStatusMap[(row as any).status] = parseInt((row as any).count, 10);
|
||||
}
|
||||
const txStatusArray: StatusCount[] = transactionsByStatus.map((row: any) => ({
|
||||
status: row.status,
|
||||
count: parseInt(row.count, 10),
|
||||
}));
|
||||
|
||||
// Calculate total approvals
|
||||
const approvedCount = statusArray.find(s => s.status === 'APPROVED')?.count || 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsByStatus: statusMap,
|
||||
totalApprovals: approvedCount,
|
||||
requestsByStatus: statusArray,
|
||||
totalApplicants,
|
||||
activeApplicants,
|
||||
totalDepartments,
|
||||
activeDepartments,
|
||||
totalDocuments,
|
||||
totalBlockchainTransactions,
|
||||
transactionsByStatus: txStatusMap,
|
||||
transactionsByStatus: txStatusArray,
|
||||
averageProcessingTime: 4.5, // Placeholder
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,8 +129,7 @@ export class AdminService {
|
||||
dbStatus = 'down';
|
||||
}
|
||||
|
||||
const overallStatus =
|
||||
dbStatus === 'up' ? 'healthy' : 'unhealthy';
|
||||
const overallStatus = dbStatus === 'up' ? 'healthy' : 'unhealthy';
|
||||
|
||||
return {
|
||||
status: overallStatus,
|
||||
@@ -138,17 +145,10 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async getRecentActivity(limit: number = 20): Promise<AuditLog[]> {
|
||||
return this.auditLogModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC')
|
||||
.limit(limit);
|
||||
return this.auditLogModel.query().orderBy('created_at', 'DESC').limit(limit);
|
||||
}
|
||||
|
||||
async getBlockchainTransactions(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: string,
|
||||
) {
|
||||
async getBlockchainTransactions(page: number = 1, limit: number = 20, status?: string) {
|
||||
const query = this.blockchainTxModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (status) {
|
||||
@@ -177,7 +177,8 @@ export class AdminService {
|
||||
department: result.department,
|
||||
apiKey: result.apiKey,
|
||||
apiSecret: result.apiSecret,
|
||||
message: 'Department onboarded successfully. Please save the API credentials as they will not be shown again.',
|
||||
message:
|
||||
'Department onboarded successfully. Please save the API credentials as they will not be shown again.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,7 +198,8 @@ export class AdminService {
|
||||
const result = await this.departmentsService.regenerateApiKey(id);
|
||||
return {
|
||||
...result,
|
||||
message: 'API key regenerated successfully. Please save the new credentials as they will not be shown again.',
|
||||
message:
|
||||
'API key regenerated successfully. Please save the new credentials as they will not be shown again.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,9 +223,7 @@ export class AdminService {
|
||||
eventType?: string,
|
||||
contractAddress?: string,
|
||||
) {
|
||||
const query = this.blockchainEventModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
const query = this.blockchainEventModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (eventType) {
|
||||
query.where({ eventType });
|
||||
@@ -255,9 +255,7 @@ export class AdminService {
|
||||
module?: string,
|
||||
search?: string,
|
||||
) {
|
||||
const query = this.appLogModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
const query = this.appLogModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (level) {
|
||||
query.where({ level });
|
||||
@@ -293,7 +291,9 @@ export class AdminService {
|
||||
const documents = await this.documentModel
|
||||
.query()
|
||||
.where({ requestId })
|
||||
.withGraphFetched('[uploadedByUser, versions.[uploadedByUser], departmentReviews.[department]]')
|
||||
.withGraphFetched(
|
||||
'[uploadedByUser, versions.[uploadedByUser], departmentReviews.[department]]',
|
||||
)
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
// Transform documents to include formatted data
|
||||
@@ -309,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: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApplicantsService } from './applicants.service';
|
||||
import { CreateApplicantDto, UpdateApplicantDto, ApplicantResponseDto } from './dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -36,10 +30,7 @@ export class ApplicantsController {
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, description: 'List of applicants' })
|
||||
async findAll(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
async findAll(@Query('page') page?: number, @Query('limit') limit?: number) {
|
||||
return this.applicantsService.findAll({ page, limit });
|
||||
}
|
||||
|
||||
@@ -53,6 +44,28 @@ export class ApplicantsController {
|
||||
return this.applicantsService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/stats')
|
||||
@Roles(UserRole.ADMIN, UserRole.APPLICANT)
|
||||
@ApiBearerAuth('BearerAuth')
|
||||
@ApiOperation({ summary: 'Get applicant statistics' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Applicant statistics',
|
||||
schema: {
|
||||
properties: {
|
||||
totalRequests: { type: 'number' },
|
||||
pendingRequests: { type: 'number' },
|
||||
approvedLicenses: { type: 'number' },
|
||||
documentsUploaded: { type: 'number' },
|
||||
blockchainRecords: { type: 'number' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Applicant not found' })
|
||||
async getStats(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.applicantsService.getStats(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiBearerAuth('BearerAuth')
|
||||
@@ -69,10 +82,7 @@ export class ApplicantsController {
|
||||
@ApiOperation({ summary: 'Update applicant' })
|
||||
@ApiResponse({ status: 200, description: 'Applicant updated', type: ApplicantResponseDto })
|
||||
@ApiResponse({ status: 404, description: 'Applicant not found' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateApplicantDto,
|
||||
) {
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApplicantDto) {
|
||||
return this.applicantsService.update(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
|
||||
import { Applicant } from '../../database/models';
|
||||
import { Injectable, NotFoundException, ConflictException, Logger, Inject } from '@nestjs/common';
|
||||
import { Applicant, LicenseRequest, Document } from '../../database/models';
|
||||
import { CreateApplicantDto, UpdateApplicantDto } from './dto';
|
||||
import { ERROR_CODES } from '../../common/constants';
|
||||
import { paginate, PaginationOptions, PaginatedResult } from '../../common/utils/pagination.util';
|
||||
|
||||
export interface ApplicantStats {
|
||||
totalRequests: number;
|
||||
pendingRequests: number;
|
||||
approvedLicenses: number;
|
||||
documentsUploaded: number;
|
||||
blockchainRecords: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApplicantsService {
|
||||
private readonly logger = new Logger(ApplicantsService.name);
|
||||
@@ -31,9 +39,7 @@ export class ApplicantsService {
|
||||
}
|
||||
|
||||
async findAll(options: PaginationOptions): Promise<PaginatedResult<Applicant>> {
|
||||
const query = Applicant.query()
|
||||
.where('is_active', true)
|
||||
.orderBy('created_at', 'desc');
|
||||
const query = Applicant.query().where('is_active', true).orderBy('created_at', 'desc');
|
||||
|
||||
return await paginate(query, options.page, options.limit);
|
||||
}
|
||||
@@ -94,4 +100,48 @@ export class ApplicantsService {
|
||||
await Applicant.query().patchAndFetchById(id, { isActive: false });
|
||||
this.logger.log(`Deactivated applicant: ${id}`);
|
||||
}
|
||||
|
||||
async getStats(id: string): Promise<ApplicantStats> {
|
||||
const applicant = await this.findById(id);
|
||||
if (!applicant) {
|
||||
throw new NotFoundException({
|
||||
code: ERROR_CODES.APPLICANT_NOT_FOUND,
|
||||
message: 'Applicant not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Get all requests for this applicant
|
||||
const requests = await LicenseRequest.query().where('applicant_id', id);
|
||||
|
||||
// Count pending requests (SUBMITTED, IN_REVIEW)
|
||||
const pendingRequests = requests.filter(r =>
|
||||
['SUBMITTED', 'IN_REVIEW'].includes(r.status),
|
||||
).length;
|
||||
|
||||
// Count approved licenses
|
||||
const approvedLicenses = requests.filter(r =>
|
||||
['APPROVED', 'COMPLETED'].includes(r.status),
|
||||
).length;
|
||||
|
||||
// Count blockchain records (requests with transaction hash)
|
||||
const blockchainRecords = requests.filter(r => r.blockchainTxHash).length;
|
||||
|
||||
// Count documents for all requests
|
||||
const requestIds = requests.map(r => r.id);
|
||||
let documentsUploaded = 0;
|
||||
if (requestIds.length > 0) {
|
||||
const documents = await Document.query()
|
||||
.whereIn('request_id', requestIds)
|
||||
.where('is_active', true);
|
||||
documentsUploaded = documents.length;
|
||||
}
|
||||
|
||||
return {
|
||||
totalRequests: requests.length,
|
||||
pendingRequests,
|
||||
approvedLicenses,
|
||||
documentsUploaded,
|
||||
blockchainRecords,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,58 @@ export class ApprovalsController {
|
||||
return this.approvalsService.requestChanges(requestId, department.id, dto, user.sub);
|
||||
}
|
||||
|
||||
@Get('pending')
|
||||
@ApiOperation({
|
||||
summary: 'Get pending approvals for current user',
|
||||
description: 'Get paginated list of pending approvals for the current department officer',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 10)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Paginated list of pending approvals',
|
||||
})
|
||||
async getPendingApprovals(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@CorrelationId() correlationId?: string,
|
||||
) {
|
||||
this.logger.debug(`[${correlationId}] Fetching pending approvals for user: ${user.sub}`);
|
||||
|
||||
if (!user.departmentCode) {
|
||||
// Return empty list for non-department users
|
||||
return {
|
||||
data: [],
|
||||
meta: { page: 1, limit: 10, total: 0, totalPages: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// Look up department by code to get ID
|
||||
const department = await this.departmentModel.query().findOne({ code: user.departmentCode });
|
||||
if (!department) {
|
||||
return {
|
||||
data: [],
|
||||
meta: { page: 1, limit: 10, total: 0, totalPages: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
return this.approvalsService.findPendingByDepartment(department.id, {
|
||||
page: parseInt(page || '1', 10),
|
||||
limit: parseInt(limit || '10', 10),
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':approvalId')
|
||||
@ApiOperation({
|
||||
summary: 'Get approval by ID',
|
||||
@@ -323,10 +375,7 @@ export class ApprovalsController {
|
||||
@CorrelationId() correlationId?: string,
|
||||
): Promise<ApprovalResponseDto[]> {
|
||||
this.logger.debug(`[${correlationId}] Fetching approvals for request: ${requestId}`);
|
||||
return this.approvalsService.findByRequestId(
|
||||
requestId,
|
||||
includeInvalidated === 'true',
|
||||
);
|
||||
return this.approvalsService.findByRequestId(requestId, includeInvalidated === 'true');
|
||||
}
|
||||
|
||||
@Get('department/:departmentCode')
|
||||
|
||||
@@ -70,21 +70,24 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
// Check if department already approved/rejected (takes priority over workflow step)
|
||||
const existingApproval = await this.approvalsRepository.query()
|
||||
const existingApproval = await this.approvalsRepository
|
||||
.query()
|
||||
.where('request_id', requestId)
|
||||
.where('department_id', departmentId)
|
||||
.whereNull('invalidated_at')
|
||||
.first();
|
||||
|
||||
if (existingApproval) {
|
||||
if (existingApproval.status === ApprovalStatus.APPROVED as any) {
|
||||
if (existingApproval.status === (ApprovalStatus.APPROVED as any)) {
|
||||
throw new BadRequestException('Request already approved by your department');
|
||||
}
|
||||
if (existingApproval.status === ApprovalStatus.REJECTED as any) {
|
||||
if (existingApproval.status === (ApprovalStatus.REJECTED as any)) {
|
||||
throw new BadRequestException('Request already rejected by your department');
|
||||
}
|
||||
if (existingApproval.status !== ApprovalStatus.PENDING as any) {
|
||||
throw new BadRequestException(`Cannot approve request with status ${existingApproval.status}`);
|
||||
if (existingApproval.status !== (ApprovalStatus.PENDING as any)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot approve request with status ${existingApproval.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +96,8 @@ export class ApprovalsService {
|
||||
const deptCode = department?.code;
|
||||
|
||||
// Check workflow step authorization
|
||||
const workflowRequest = await this.requestsRepository.query()
|
||||
const workflowRequest = await this.requestsRepository
|
||||
.query()
|
||||
.findById(requestId)
|
||||
.withGraphFetched('workflow');
|
||||
|
||||
@@ -116,19 +120,19 @@ export class ApprovalsService {
|
||||
// Check if department is in current stage
|
||||
if (currentStageIndex < definition.stages.length) {
|
||||
const currentStage = definition.stages[currentStageIndex];
|
||||
const isInCurrentStage = currentStage.requiredApprovals?.some((ra: any) =>
|
||||
ra.departmentCode === deptCode
|
||||
const isInCurrentStage = currentStage.requiredApprovals?.some(
|
||||
(ra: any) => ra.departmentCode === deptCode,
|
||||
);
|
||||
|
||||
if (!isInCurrentStage) {
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to the current workflow step'
|
||||
'Your department is not assigned to the current workflow step',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// All stages complete - department not in any active stage
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to the current workflow step'
|
||||
'Your department is not assigned to the current workflow step',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -137,9 +141,7 @@ export class ApprovalsService {
|
||||
// Then check authorization
|
||||
const approval = await this.findPendingApproval(requestId, departmentId);
|
||||
if (!approval) {
|
||||
throw new ForbiddenException(
|
||||
'Your department is not assigned to approve this request',
|
||||
);
|
||||
throw new ForbiddenException('Your department is not assigned to approve this request');
|
||||
}
|
||||
|
||||
// Use comments if remarks is not provided
|
||||
@@ -156,9 +158,8 @@ export class ApprovalsService {
|
||||
}
|
||||
|
||||
// Generate blockchain transaction hash for the approval
|
||||
const blockchainTxHash = '0x' + Array.from({ length: 64 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('');
|
||||
const blockchainTxHash =
|
||||
'0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
|
||||
const saved = await approval.$query().patchAndFetch({
|
||||
status: (dto.status || ApprovalStatus.APPROVED) as any,
|
||||
@@ -169,7 +170,8 @@ export class ApprovalsService {
|
||||
});
|
||||
|
||||
// Fetch with department relation
|
||||
const result = await this.approvalsRepository.query()
|
||||
const result = await this.approvalsRepository
|
||||
.query()
|
||||
.findById(saved.id)
|
||||
.withGraphFetched('department');
|
||||
|
||||
@@ -200,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,
|
||||
|
||||
@@ -7,7 +7,8 @@ export class RejectRequestDto {
|
||||
description: 'Detailed remarks explaining the rejection',
|
||||
minLength: 5,
|
||||
maxLength: 1000,
|
||||
example: 'The fire safety certificate provided is expired. Please provide an updated certificate.',
|
||||
example:
|
||||
'The fire safety certificate provided is expired. Please provide an updated certificate.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Param,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Query, Param, UseGuards, Logger } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -30,20 +23,43 @@ export class AuditController {
|
||||
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Query audit logs',
|
||||
description: 'Get paginated audit logs with optional filters by entity, action, actor, and date range',
|
||||
description:
|
||||
'Get paginated audit logs with optional filters by entity, action, actor, and date range',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'entityType',
|
||||
required: false,
|
||||
description: 'Filter by entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)',
|
||||
})
|
||||
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)' })
|
||||
@ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, APPROVE, REJECT, etc.)' })
|
||||
@ApiQuery({ name: 'actorType', required: false, description: 'Filter by actor type (APPLICANT, DEPARTMENT, SYSTEM, ADMIN)' })
|
||||
@ApiQuery({
|
||||
name: 'action',
|
||||
required: false,
|
||||
description: 'Filter by action (CREATE, UPDATE, DELETE, APPROVE, REJECT, etc.)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'actorType',
|
||||
required: false,
|
||||
description: 'Filter by actor type (APPLICANT, DEPARTMENT, SYSTEM, ADMIN)',
|
||||
})
|
||||
@ApiQuery({ name: 'actorId', required: false, description: 'Filter by actor ID' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: 'Filter from date (ISO 8601)' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: 'Filter to date (ISO 8601)' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 20)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Paginated audit logs' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
|
||||
@@ -68,13 +84,13 @@ export class AuditController {
|
||||
summary: 'Get audit trail for entity',
|
||||
description: 'Get complete audit trail for a specific entity (e.g., all changes to a request)',
|
||||
})
|
||||
@ApiParam({ name: 'entityType', description: 'Entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)' })
|
||||
@ApiParam({
|
||||
name: 'entityType',
|
||||
description: 'Entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)',
|
||||
})
|
||||
@ApiParam({ name: 'entityId', description: 'Entity ID (UUID)' })
|
||||
@ApiResponse({ status: 200, description: 'Audit trail for entity' })
|
||||
async findByEntity(
|
||||
@Param('entityType') entityType: string,
|
||||
@Param('entityId') entityId: string,
|
||||
) {
|
||||
async findByEntity(@Param('entityType') entityType: string, @Param('entityId') entityId: string) {
|
||||
return this.auditService.findByEntity(entityType, entityId);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,7 @@ export class AuditService {
|
||||
) {}
|
||||
|
||||
async record(dto: CreateAuditLogDto): Promise<AuditLog> {
|
||||
this.logger.debug(
|
||||
`Recording audit: ${dto.action} on ${dto.entityType}/${dto.entityId}`,
|
||||
);
|
||||
this.logger.debug(`Recording audit: ${dto.action} on ${dto.entityType}/${dto.entityId}`);
|
||||
|
||||
return this.auditLogModel.query().insert({
|
||||
entityType: dto.entityType,
|
||||
@@ -106,15 +104,21 @@ export class AuditService {
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
// Transform to add performedBy and details fields from newValue
|
||||
return logs.map((log) => ({
|
||||
return logs.map(log => ({
|
||||
...log,
|
||||
performedBy: (log.newValue as any)?.performedBy,
|
||||
details: (log.newValue as any)?.reason || (log.newValue as any)?.remarks ||
|
||||
(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),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
EmailPasswordLoginDto,
|
||||
LoginResponseDto,
|
||||
DigiLockerLoginResponseDto,
|
||||
UserLoginResponseDto
|
||||
UserLoginResponseDto,
|
||||
} from './dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
|
||||
@@ -139,9 +139,7 @@ export class AuthService {
|
||||
/**
|
||||
* Email/Password login for all user types (Admin, Department, Citizen)
|
||||
*/
|
||||
async emailPasswordLogin(
|
||||
dto: EmailPasswordLoginDto,
|
||||
): Promise<{
|
||||
async emailPasswordLogin(dto: EmailPasswordLoginDto): Promise<{
|
||||
accessToken: string;
|
||||
user: {
|
||||
id: string;
|
||||
|
||||
@@ -18,6 +18,6 @@ export class RolesGuard implements CanActivate {
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user?.role === role);
|
||||
return requiredRoles.some(role => user?.role === role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,10 @@ export class ApprovalChainService {
|
||||
try {
|
||||
this.logger.debug(`Recording approval for request: ${requestId}`);
|
||||
|
||||
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any;
|
||||
const contract = this.web3Service.getContract(
|
||||
contractAddress,
|
||||
this.approvalManagerAbi,
|
||||
) as any;
|
||||
|
||||
// Map status string to enum number
|
||||
const statusMap = {
|
||||
@@ -66,7 +69,10 @@ export class ApprovalChainService {
|
||||
requestId: string,
|
||||
): Promise<OnChainApproval[]> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any;
|
||||
const contract = this.web3Service.getContract(
|
||||
contractAddress,
|
||||
this.approvalManagerAbi,
|
||||
) as any;
|
||||
|
||||
const approvals = await contract.getRequestApprovals(requestId);
|
||||
|
||||
@@ -89,14 +95,14 @@ export class ApprovalChainService {
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateApproval(
|
||||
contractAddress: string,
|
||||
approvalId: string,
|
||||
): Promise<string> {
|
||||
async invalidateApproval(contractAddress: string, approvalId: string): Promise<string> {
|
||||
try {
|
||||
this.logger.debug(`Invalidating approval: ${approvalId}`);
|
||||
|
||||
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any;
|
||||
const contract = this.web3Service.getContract(
|
||||
contractAddress,
|
||||
this.approvalManagerAbi,
|
||||
) as any;
|
||||
|
||||
const tx = await contract.invalidateApproval(approvalId);
|
||||
const receipt = await this.web3Service.sendTransaction(tx);
|
||||
@@ -115,7 +121,10 @@ export class ApprovalChainService {
|
||||
remarksHash: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any;
|
||||
const contract = this.web3Service.getContract(
|
||||
contractAddress,
|
||||
this.approvalManagerAbi,
|
||||
) as any;
|
||||
|
||||
return await contract.verifyApproval(approvalId, remarksHash);
|
||||
} catch (error) {
|
||||
@@ -124,12 +133,12 @@ export class ApprovalChainService {
|
||||
}
|
||||
}
|
||||
|
||||
async getApprovalDetails(
|
||||
contractAddress: string,
|
||||
approvalId: string,
|
||||
): Promise<OnChainApproval> {
|
||||
async getApprovalDetails(contractAddress: string, approvalId: string): Promise<OnChainApproval> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.approvalManagerAbi) as any;
|
||||
const contract = this.web3Service.getContract(
|
||||
contractAddress,
|
||||
this.approvalManagerAbi,
|
||||
) as any;
|
||||
|
||||
const approval = await contract.getApprovalDetails(approvalId);
|
||||
|
||||
|
||||
@@ -77,9 +77,7 @@ export class BlockchainMonitorService {
|
||||
await this.sleep(this.POLL_INTERVAL);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
this.logger.warn(
|
||||
`Error polling transaction ${txHash}: ${lastError.message}`,
|
||||
);
|
||||
this.logger.warn(`Error polling transaction ${txHash}: ${lastError.message}`);
|
||||
attempts++;
|
||||
await this.sleep(this.POLL_INTERVAL);
|
||||
}
|
||||
@@ -178,7 +176,8 @@ export class BlockchainMonitorService {
|
||||
errorMessage?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.blockchainTxRepository.query()
|
||||
await this.blockchainTxRepository
|
||||
.query()
|
||||
.where({ txHash })
|
||||
.patch({
|
||||
status,
|
||||
@@ -188,14 +187,11 @@ export class BlockchainMonitorService {
|
||||
errorMessage: errorMessage || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to update transaction status in database: ${txHash}`,
|
||||
error,
|
||||
);
|
||||
this.logger.error(`Failed to update transaction status in database: ${txHash}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,7 @@ export class DocumentChainService {
|
||||
this.logger.log(`Document hash recorded: ${receipt.hash}`);
|
||||
return receipt.hash!;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to record document hash for ${documentId}`,
|
||||
error,
|
||||
);
|
||||
this.logger.error(`Failed to record document hash for ${documentId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -52,9 +49,7 @@ export class DocumentChainService {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any;
|
||||
|
||||
const isValid = await contract.verifyDocumentHash(documentId, hash);
|
||||
this.logger.debug(
|
||||
`Document hash verification: documentId=${documentId}, isValid=${isValid}`,
|
||||
);
|
||||
this.logger.debug(`Document hash verification: documentId=${documentId}, isValid=${isValid}`);
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to verify document hash for ${documentId}`, error);
|
||||
@@ -85,10 +80,7 @@ export class DocumentChainService {
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestDocumentHash(
|
||||
contractAddress: string,
|
||||
documentId: string,
|
||||
): Promise<string> {
|
||||
async getLatestDocumentHash(contractAddress: string, documentId: string): Promise<string> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any;
|
||||
|
||||
|
||||
@@ -74,10 +74,7 @@ export class LicenseNFTService {
|
||||
}
|
||||
}
|
||||
|
||||
async getLicenseMetadata(
|
||||
contractAddress: string,
|
||||
tokenId: bigint,
|
||||
): Promise<LicenseMetadata> {
|
||||
async getLicenseMetadata(contractAddress: string, tokenId: bigint): Promise<LicenseMetadata> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any;
|
||||
|
||||
@@ -135,10 +132,7 @@ export class LicenseNFTService {
|
||||
}
|
||||
}
|
||||
|
||||
async getTokenIdByRequest(
|
||||
contractAddress: string,
|
||||
requestId: string,
|
||||
): Promise<bigint | null> {
|
||||
async getTokenIdByRequest(contractAddress: string, requestId: string): Promise<bigint | null> {
|
||||
try {
|
||||
const contract = this.web3Service.getContract(contractAddress, this.licenseNFTAbi) as any;
|
||||
return await contract.tokenOfRequest(requestId);
|
||||
@@ -148,10 +142,7 @@ export class LicenseNFTService {
|
||||
}
|
||||
}
|
||||
|
||||
async verifyLicense(
|
||||
contractAddress: string,
|
||||
tokenId: bigint,
|
||||
): Promise<LicenseVerification> {
|
||||
async verifyLicense(contractAddress: string, tokenId: bigint): Promise<LicenseVerification> {
|
||||
try {
|
||||
const isValid = await this.isLicenseValid(contractAddress, tokenId);
|
||||
const metadata = await this.getLicenseMetadata(contractAddress, tokenId);
|
||||
|
||||
@@ -53,10 +53,7 @@ export class Web3Service implements OnModuleInit {
|
||||
return this.wallet;
|
||||
}
|
||||
|
||||
getContract<T extends ethers.BaseContract>(
|
||||
address: string,
|
||||
abi: ethers.InterfaceAbi,
|
||||
): T {
|
||||
getContract<T extends ethers.BaseContract>(address: string, abi: ethers.InterfaceAbi): T {
|
||||
if (!this.wallet) {
|
||||
throw new Error('Wallet not initialized');
|
||||
}
|
||||
@@ -116,14 +113,12 @@ export class Web3Service implements OnModuleInit {
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
this.logger.warn(
|
||||
`Transaction attempt ${attempt + 1} failed: ${lastError.message}`,
|
||||
);
|
||||
this.logger.warn(`Transaction attempt ${attempt + 1} failed: ${lastError.message}`);
|
||||
|
||||
// Check if it's a nonce issue
|
||||
if (lastError.message.includes('nonce') || lastError.message.includes('replacement')) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,10 @@ export class WalletService {
|
||||
/**
|
||||
* Get wallet by owner
|
||||
*/
|
||||
async getWalletByOwner(ownerType: 'USER' | 'DEPARTMENT', ownerId: string): Promise<Wallet | undefined> {
|
||||
async getWalletByOwner(
|
||||
ownerType: 'USER' | 'DEPARTMENT',
|
||||
ownerId: string,
|
||||
): Promise<Wallet | undefined> {
|
||||
return this.walletModel.query().findOne({
|
||||
ownerType,
|
||||
ownerId,
|
||||
|
||||
@@ -139,12 +139,10 @@ export class DepartmentsController {
|
||||
`[${correlationId}] Fetching departments - page: ${query.page}, limit: ${query.limit}`,
|
||||
);
|
||||
|
||||
const { results, total } = await this.departmentsService.findAll(query);
|
||||
const { data, total } = await this.departmentsService.findAll(query);
|
||||
|
||||
return {
|
||||
data: results.map((department) =>
|
||||
DepartmentResponseDto.fromEntity(department),
|
||||
),
|
||||
data: data.map(department => DepartmentResponseDto.fromEntity(department)),
|
||||
meta: {
|
||||
total,
|
||||
page: query.page,
|
||||
@@ -154,17 +152,17 @@ export class DepartmentsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':code')
|
||||
@Get(':identifier')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get department by code',
|
||||
description: 'Retrieve detailed information about a specific department',
|
||||
summary: 'Get department by ID or code',
|
||||
description: 'Retrieve detailed information about a specific department by UUID or code',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -179,27 +177,33 @@ export class DepartmentsController {
|
||||
status: 404,
|
||||
description: 'Department not found',
|
||||
})
|
||||
async findByCode(
|
||||
@Param('code') code: string,
|
||||
async findByIdentifier(
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<DepartmentResponseDto> {
|
||||
this.logger.debug(`[${correlationId}] Fetching department: ${code}`);
|
||||
this.logger.debug(`[${correlationId}] Fetching department: ${identifier}`);
|
||||
|
||||
try {
|
||||
const department = await this.departmentsService.findByCode(code);
|
||||
// Check if identifier is a UUID format
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const isUuid = uuidRegex.test(identifier);
|
||||
|
||||
const department = isUuid
|
||||
? await this.departmentsService.findById(identifier)
|
||||
: await this.departmentsService.findByCode(identifier);
|
||||
|
||||
return DepartmentResponseDto.fromEntity(department);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(
|
||||
`[${correlationId}] Error fetching department: ${error.message}`,
|
||||
);
|
||||
this.logger.error(`[${correlationId}] Error fetching department: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Patch(':code')
|
||||
@Patch(':identifier')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -208,9 +212,9 @@ export class DepartmentsController {
|
||||
description: 'Update department information (admin only)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -230,25 +234,26 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async update(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@Body() updateDepartmentDto: UpdateDepartmentDto,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<DepartmentResponseDto> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Updating department: ${code}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Updating department: ${identifier}`);
|
||||
|
||||
const department = await this.departmentsService.findByCode(code);
|
||||
const updated = await this.departmentsService.update(
|
||||
department.id,
|
||||
updateDepartmentDto,
|
||||
);
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const isUuid = uuidRegex.test(identifier);
|
||||
|
||||
this.logger.log(`[${correlationId}] Department updated: ${code}`);
|
||||
const department = isUuid
|
||||
? await this.departmentsService.findById(identifier)
|
||||
: await this.departmentsService.findByCode(identifier);
|
||||
|
||||
const updated = await this.departmentsService.update(department.id, updateDepartmentDto);
|
||||
|
||||
this.logger.log(`[${correlationId}] Department updated: ${identifier}`);
|
||||
return DepartmentResponseDto.fromEntity(updated);
|
||||
}
|
||||
|
||||
@Post(':code/regenerate-api-key')
|
||||
@Post(':identifier/regenerate-api-key')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -259,9 +264,9 @@ export class DepartmentsController {
|
||||
'Generate a new API key pair for the department (admin only). Old key will be invalidated.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -282,33 +287,35 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async regenerateApiKey(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<{ apiKey: string; apiSecret: string }> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Regenerating API key for department: ${code}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Regenerating API key for department: ${identifier}`);
|
||||
|
||||
const department = await this.departmentsService.findByCode(code);
|
||||
const result = await this.departmentsService.regenerateApiKey(
|
||||
department.id,
|
||||
);
|
||||
const department = await this.findDepartmentByIdentifier(identifier);
|
||||
const result = await this.departmentsService.regenerateApiKey(department.id);
|
||||
|
||||
this.logger.log(
|
||||
`[${correlationId}] API key regenerated for department: ${code}`,
|
||||
);
|
||||
this.logger.log(`[${correlationId}] API key regenerated for department: ${identifier}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':code/stats')
|
||||
private async findDepartmentByIdentifier(identifier: string) {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const isUuid = uuidRegex.test(identifier);
|
||||
return isUuid
|
||||
? await this.departmentsService.findById(identifier)
|
||||
: await this.departmentsService.findByCode(identifier);
|
||||
}
|
||||
|
||||
@Get(':identifier/stats')
|
||||
@ApiOperation({
|
||||
summary: 'Get department statistics',
|
||||
description: 'Retrieve statistics for a specific department',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'startDate',
|
||||
@@ -332,14 +339,12 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async getStats(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@Query('startDate') startDateStr?: string,
|
||||
@Query('endDate') endDateStr?: string,
|
||||
@CorrelationId() correlationId?: string,
|
||||
): Promise<DepartmentStatsDto> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Fetching statistics for department: ${code}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Fetching statistics for department: ${identifier}`);
|
||||
|
||||
let startDate: Date | undefined;
|
||||
let endDate: Date | undefined;
|
||||
@@ -361,11 +366,12 @@ export class DepartmentsController {
|
||||
throw new BadRequestException('Invalid date format');
|
||||
}
|
||||
|
||||
const stats = await this.departmentsService.getStats(code, startDate, endDate);
|
||||
const department = await this.findDepartmentByIdentifier(identifier);
|
||||
const stats = await this.departmentsService.getStats(department.code, startDate, endDate);
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Post(':code/activate')
|
||||
@Post(':identifier/activate')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -375,9 +381,9 @@ export class DepartmentsController {
|
||||
description: 'Activate a deactivated department (admin only)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -393,19 +399,19 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async activate(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<DepartmentResponseDto> {
|
||||
this.logger.debug(`[${correlationId}] Activating department: ${code}`);
|
||||
this.logger.debug(`[${correlationId}] Activating department: ${identifier}`);
|
||||
|
||||
const department = await this.departmentsService.findByCode(code);
|
||||
const department = await this.findDepartmentByIdentifier(identifier);
|
||||
const activated = await this.departmentsService.activate(department.id);
|
||||
|
||||
this.logger.log(`[${correlationId}] Department activated: ${code}`);
|
||||
this.logger.log(`[${correlationId}] Department activated: ${identifier}`);
|
||||
return DepartmentResponseDto.fromEntity(activated);
|
||||
}
|
||||
|
||||
@Post(':code/deactivate')
|
||||
@Post(':identifier/deactivate')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@@ -415,9 +421,9 @@ export class DepartmentsController {
|
||||
description: 'Deactivate a department (admin only)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
description: 'Department code',
|
||||
example: 'DEPT_001',
|
||||
name: 'identifier',
|
||||
description: 'Department ID (UUID) or code',
|
||||
example: 'REVENUE_DEPT',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -432,15 +438,15 @@ export class DepartmentsController {
|
||||
description: 'Department not found',
|
||||
})
|
||||
async deactivate(
|
||||
@Param('code') code: string,
|
||||
@Param('identifier') identifier: string,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.debug(`[${correlationId}] Deactivating department: ${code}`);
|
||||
this.logger.debug(`[${correlationId}] Deactivating department: ${identifier}`);
|
||||
|
||||
const department = await this.departmentsService.findByCode(code);
|
||||
const department = await this.findDepartmentByIdentifier(identifier);
|
||||
await this.departmentsService.deactivate(department.id);
|
||||
|
||||
this.logger.log(`[${correlationId}] Department deactivated: ${code}`);
|
||||
return { message: `Department ${code} has been deactivated` };
|
||||
this.logger.log(`[${correlationId}] Department deactivated: ${identifier}`);
|
||||
return { message: `Department ${department.code} has been deactivated` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ export class DepartmentsService {
|
||||
});
|
||||
|
||||
if (existingDepartment) {
|
||||
throw new BadRequestException(
|
||||
`Department with code ${dto.code} already exists`,
|
||||
);
|
||||
throw new BadRequestException(`Department with code ${dto.code} already exists`);
|
||||
}
|
||||
|
||||
// Generate API key pair
|
||||
@@ -84,7 +82,9 @@ export class DepartmentsService {
|
||||
walletAddress: wallet.address,
|
||||
});
|
||||
|
||||
this.logger.log(`Department created successfully: ${updatedDepartment.id} with wallet: ${wallet.address}`);
|
||||
this.logger.log(
|
||||
`Department created successfully: ${updatedDepartment.id} with wallet: ${wallet.address}`,
|
||||
);
|
||||
|
||||
return {
|
||||
department: updatedDepartment,
|
||||
@@ -99,12 +99,9 @@ export class DepartmentsService {
|
||||
* @returns Promise<PaginatedResult<Department>>
|
||||
*/
|
||||
async findAll(query: PaginationDto): Promise<PaginatedResult<Department>> {
|
||||
this.logger.debug(
|
||||
`Fetching departments - page: ${query.page}, limit: ${query.limit}`,
|
||||
);
|
||||
this.logger.debug(`Fetching departments - page: ${query.page}, limit: ${query.limit}`);
|
||||
|
||||
const queryBuilder = this.departmentRepository.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
const queryBuilder = this.departmentRepository.query().orderBy('created_at', 'DESC');
|
||||
|
||||
return await paginate(queryBuilder, query.page, query.limit);
|
||||
}
|
||||
@@ -139,9 +136,7 @@ export class DepartmentsService {
|
||||
});
|
||||
|
||||
if (!department) {
|
||||
throw new NotFoundException(
|
||||
`Department with code ${code} not found`,
|
||||
);
|
||||
throw new NotFoundException(`Department with code ${code} not found`);
|
||||
}
|
||||
|
||||
return department;
|
||||
@@ -165,9 +160,7 @@ export class DepartmentsService {
|
||||
});
|
||||
|
||||
if (existingDepartment) {
|
||||
throw new ConflictException(
|
||||
`Department with code ${dto.code} already exists`,
|
||||
);
|
||||
throw new ConflictException(`Department with code ${dto.code} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +197,7 @@ export class DepartmentsService {
|
||||
* @param webhookSecret - Webhook secret for HMAC verification
|
||||
* @returns Promise<Department>
|
||||
*/
|
||||
async updateWebhook(
|
||||
id: string,
|
||||
webhookUrl: string,
|
||||
webhookSecret?: string,
|
||||
): Promise<Department> {
|
||||
async updateWebhook(id: string, webhookUrl: string, webhookSecret?: string): Promise<Department> {
|
||||
this.logger.debug(`Updating webhook for department: ${id}`);
|
||||
|
||||
const department = await this.findById(id);
|
||||
@@ -229,11 +218,7 @@ export class DepartmentsService {
|
||||
* @param endDate - End date for statistics
|
||||
* @returns Promise<DepartmentStatsDto>
|
||||
*/
|
||||
async getStats(
|
||||
code: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<DepartmentStatsDto> {
|
||||
async getStats(code: string, startDate?: Date, endDate?: Date): Promise<DepartmentStatsDto> {
|
||||
this.logger.debug(
|
||||
`Fetching statistics for department: ${code} from ${startDate} to ${endDate}`,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
IsUrl,
|
||||
IsEmail,
|
||||
} from 'class-validator';
|
||||
import { IsString, Matches, MinLength, IsOptional, IsUrl, IsEmail } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateDepartmentDto {
|
||||
|
||||
@@ -58,8 +58,7 @@ export class DepartmentStatsDto {
|
||||
|
||||
constructor() {
|
||||
if (this.totalApplicants > 0) {
|
||||
this.issueRate =
|
||||
(this.totalCredentialsIssued / this.totalApplicants) * 100;
|
||||
this.issueRate = (this.totalCredentialsIssued / this.totalApplicants) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDepartmentDto } from './create-department.dto';
|
||||
import {
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
IsUrl,
|
||||
IsEmail,
|
||||
} from 'class-validator';
|
||||
import { IsString, Matches, MinLength, IsOptional, IsUrl, IsEmail } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {
|
||||
|
||||
@@ -75,7 +75,12 @@ export class DocumentsController {
|
||||
})
|
||||
async uploadDocumentAlt(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() uploadDto: UploadDocumentDto & { requestId: string; documentType: string; description?: string },
|
||||
@Body()
|
||||
uploadDto: UploadDocumentDto & {
|
||||
requestId: string;
|
||||
documentType: string;
|
||||
description?: string;
|
||||
},
|
||||
@CurrentUser() user: any,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<any> {
|
||||
@@ -99,7 +104,7 @@ export class DocumentsController {
|
||||
uploadDto.requestId,
|
||||
file,
|
||||
{ docType: uploadDto.documentType, description: uploadDto.description },
|
||||
user.sub
|
||||
user.sub,
|
||||
);
|
||||
|
||||
const storagePath = `requests/${uploadDto.requestId}/${uploadDto.documentType}`;
|
||||
@@ -222,6 +227,10 @@ export class DocumentsController {
|
||||
status: 404,
|
||||
description: 'Document not found',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Access denied',
|
||||
})
|
||||
async downloadDocument(
|
||||
@Param('documentId') documentId: string,
|
||||
@Query('version') version: string,
|
||||
@@ -230,10 +239,27 @@ export class DocumentsController {
|
||||
@CorrelationId() correlationId: string,
|
||||
@Res() res: any,
|
||||
): Promise<void> {
|
||||
this.logger.debug(`[${correlationId}] Downloading document: ${documentId}, version: ${version}, inline: ${inline}`);
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Downloading document: ${documentId}, version: ${version}, inline: ${inline}`,
|
||||
);
|
||||
|
||||
// Validate documentId format
|
||||
if (!documentId || documentId === 'undefined' || documentId === 'null') {
|
||||
throw new BadRequestException('Invalid document ID');
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
const canAccess = await this.documentsService.checkUserCanAccessDocument(user.sub, documentId);
|
||||
let canAccess: boolean;
|
||||
try {
|
||||
canAccess = await this.documentsService.checkUserCanAccessDocument(user.sub, documentId);
|
||||
} catch (error: any) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`[${correlationId}] Error checking document access: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException({
|
||||
code: 'AUTH_008',
|
||||
@@ -241,16 +267,41 @@ export class DocumentsController {
|
||||
});
|
||||
}
|
||||
|
||||
const versionNum = version ? parseInt(version) : undefined;
|
||||
const { buffer, mimeType, fileName, fileSize, hash } = await this.documentsService.getFileContent(documentId, versionNum, user.sub);
|
||||
const versionNum = version ? parseInt(version, 10) : undefined;
|
||||
|
||||
// Validate version number if provided
|
||||
if (version && (isNaN(versionNum) || versionNum < 1)) {
|
||||
throw new BadRequestException('Invalid version number');
|
||||
}
|
||||
|
||||
let fileContent: {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
hash: string;
|
||||
};
|
||||
try {
|
||||
fileContent = await this.documentsService.getFileContent(documentId, versionNum, user.sub);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`[${correlationId}] Error retrieving file content for document ${documentId}: ${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { buffer, mimeType, fileName, fileSize, hash } = fileContent;
|
||||
|
||||
// Sanitize filename for Content-Disposition header
|
||||
const sanitizedFileName = fileName.replace(/[^\w\s.-]/g, '_').replace(/\s+/g, '_');
|
||||
|
||||
const disposition = inline === 'true' ? 'inline' : 'attachment';
|
||||
|
||||
res.set({
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': fileSize,
|
||||
'Content-Disposition': `${disposition}; filename="${fileName}"`,
|
||||
'X-File-Hash': hash,
|
||||
'Content-Type': mimeType || 'application/octet-stream',
|
||||
'Content-Length': fileSize || buffer.length,
|
||||
'Content-Disposition': `${disposition}; filename="${sanitizedFileName}"`,
|
||||
'X-File-Hash': hash || '',
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
@@ -284,7 +335,7 @@ export class DocumentsController {
|
||||
this.logger.debug(`[${correlationId}] Fetching versions for document: ${documentId}`);
|
||||
|
||||
const versions = await this.documentsService.getVersions(documentId);
|
||||
return versions.map((v) => ({
|
||||
return versions.map(v => ({
|
||||
id: v.id,
|
||||
documentId: v.documentId,
|
||||
version: v.version,
|
||||
@@ -340,9 +391,7 @@ export class DocumentsController {
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@CorrelationId() correlationId: string,
|
||||
): Promise<DocumentResponseDto> {
|
||||
this.logger.debug(
|
||||
`[${correlationId}] Uploading new version for document: ${documentId}`,
|
||||
);
|
||||
this.logger.debug(`[${correlationId}] Uploading new version for document: ${documentId}`);
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('File is required');
|
||||
@@ -405,10 +454,26 @@ export class DocumentsController {
|
||||
this.logger.debug(`[${correlationId}] Fetching documents for request: ${requestId}`);
|
||||
|
||||
const documents = await this.documentsService.findByRequestId(requestId);
|
||||
return documents.map((d) => this.mapToResponseDto(d));
|
||||
return documents.map(d => this.mapToResponseDto(d));
|
||||
}
|
||||
|
||||
private mapToResponseDto(document: any): DocumentResponseDto {
|
||||
// Get file size and mimeType from the latest version if available
|
||||
let fileSize: number | undefined;
|
||||
let mimeType: string | undefined;
|
||||
|
||||
if (document.versions && document.versions.length > 0) {
|
||||
// Sort versions by version number descending and get the latest
|
||||
const latestVersion = document.versions.reduce(
|
||||
(latest: any, v: any) => (v.version > (latest?.version || 0) ? v : latest),
|
||||
null,
|
||||
);
|
||||
if (latestVersion) {
|
||||
fileSize = parseInt(latestVersion.fileSize, 10) || undefined;
|
||||
mimeType = latestVersion.mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: document.id,
|
||||
requestId: document.requestId,
|
||||
@@ -417,6 +482,8 @@ export class DocumentsController {
|
||||
currentVersion: document.currentVersion,
|
||||
currentHash: document.currentHash,
|
||||
fileHash: document.currentHash,
|
||||
fileSize,
|
||||
mimeType,
|
||||
minioBucket: document.minioBucket,
|
||||
isActive: document.isActive,
|
||||
downloadCount: document.downloadCount || 0,
|
||||
|
||||
@@ -6,10 +6,7 @@ import { MinioService } from './services/minio.service';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
AuditModule,
|
||||
],
|
||||
imports: [ConfigModule, AuditModule],
|
||||
controllers: [DocumentsController],
|
||||
providers: [DocumentsService, MinioService],
|
||||
exports: [DocumentsService, MinioService],
|
||||
|
||||
@@ -24,9 +24,19 @@ export class DocumentsService {
|
||||
private readonly MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
private readonly ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
private readonly ALLOWED_DOC_TYPES = [
|
||||
'FLOOR_PLAN', 'PHOTOGRAPH', 'ID_PROOF', 'ADDRESS_PROOF',
|
||||
'NOC', 'LICENSE_COPY', 'OTHER', 'FIRE_SAFETY', 'HEALTH_CERT',
|
||||
'TAX_CLEARANCE', 'SITE_PLAN', 'BUILDING_PERMIT', 'BUSINESS_LICENSE'
|
||||
'FLOOR_PLAN',
|
||||
'PHOTOGRAPH',
|
||||
'ID_PROOF',
|
||||
'ADDRESS_PROOF',
|
||||
'NOC',
|
||||
'LICENSE_COPY',
|
||||
'OTHER',
|
||||
'FIRE_SAFETY',
|
||||
'HEALTH_CERT',
|
||||
'TAX_CLEARANCE',
|
||||
'SITE_PLAN',
|
||||
'BUILDING_PERMIT',
|
||||
'BUSINESS_LICENSE',
|
||||
];
|
||||
|
||||
constructor(
|
||||
@@ -56,7 +66,10 @@ export class DocumentsService {
|
||||
.replace(/\\/g, '_');
|
||||
}
|
||||
|
||||
async checkUserCanAccessRequest(userId: string, requestId: string): Promise<{ canAccess: boolean; isAdmin: boolean }> {
|
||||
async checkUserCanAccessRequest(
|
||||
userId: string,
|
||||
requestId: string,
|
||||
): Promise<{ canAccess: boolean; isAdmin: boolean }> {
|
||||
const user = await this.userRepository.query().findById(userId);
|
||||
if (!user) {
|
||||
return { canAccess: false, isAdmin: false };
|
||||
@@ -67,7 +80,8 @@ export class DocumentsService {
|
||||
return { canAccess: true, isAdmin: true };
|
||||
}
|
||||
|
||||
const request = await this.requestRepository.query()
|
||||
const request = await this.requestRepository
|
||||
.query()
|
||||
.findById(requestId)
|
||||
.withGraphFetched('applicant');
|
||||
|
||||
@@ -83,7 +97,8 @@ export class DocumentsService {
|
||||
|
||||
// Check if user is from an assigned department
|
||||
if (user.role === 'DEPARTMENT' && user.departmentId) {
|
||||
const approvals = await this.approvalRepository.query()
|
||||
const approvals = await this.approvalRepository
|
||||
.query()
|
||||
.where({ request_id: requestId, department_id: user.departmentId });
|
||||
if (approvals.length > 0) {
|
||||
return { canAccess: true, isAdmin: false };
|
||||
@@ -134,7 +149,9 @@ export class DocumentsService {
|
||||
'uploaded-by': userId,
|
||||
});
|
||||
|
||||
let document = await this.documentRepository.query().findOne({ request_id: requestId, doc_type: dto.docType });
|
||||
const document = await this.documentRepository
|
||||
.query()
|
||||
.findOne({ request_id: requestId, doc_type: dto.docType });
|
||||
|
||||
let savedDocument;
|
||||
let currentVersion = 1;
|
||||
@@ -206,7 +223,10 @@ export class DocumentsService {
|
||||
async findById(id: string): Promise<Document> {
|
||||
this.logger.debug(`Finding document: ${id}`);
|
||||
|
||||
const document = await this.documentRepository.query().findById(id).withGraphFetched('versions');
|
||||
const document = await this.documentRepository
|
||||
.query()
|
||||
.findById(id)
|
||||
.withGraphFetched('versions');
|
||||
|
||||
if (!document) {
|
||||
throw new NotFoundException(`Document not found: ${id}`);
|
||||
@@ -218,7 +238,8 @@ export class DocumentsService {
|
||||
async findByRequestId(requestId: string): Promise<Document[]> {
|
||||
this.logger.debug(`Finding documents for request: ${requestId}`);
|
||||
|
||||
return await this.documentRepository.query()
|
||||
return await this.documentRepository
|
||||
.query()
|
||||
.where({ request_id: requestId, is_active: true })
|
||||
.withGraphFetched('versions')
|
||||
.orderBy('created_at', 'DESC');
|
||||
@@ -229,7 +250,8 @@ export class DocumentsService {
|
||||
|
||||
await this.findById(documentId);
|
||||
|
||||
return await this.documentVersionRepository.query()
|
||||
return await this.documentVersionRepository
|
||||
.query()
|
||||
.where({ document_id: documentId })
|
||||
.orderBy('version', 'DESC');
|
||||
}
|
||||
@@ -251,7 +273,10 @@ export class DocumentsService {
|
||||
return this.upload(requestId, file, { docType: document.docType }, userId);
|
||||
}
|
||||
|
||||
async getDownloadUrl(documentId: string, version?: number): Promise<{ url: string; expiresAt: Date }> {
|
||||
async getDownloadUrl(
|
||||
documentId: string,
|
||||
version?: number,
|
||||
): Promise<{ url: string; expiresAt: Date }> {
|
||||
this.logger.debug(`Generating download URL for document: ${documentId}, version: ${version}`);
|
||||
|
||||
const document = await this.findById(documentId);
|
||||
@@ -259,13 +284,16 @@ export class DocumentsService {
|
||||
let targetVersion: DocumentVersion;
|
||||
|
||||
if (version) {
|
||||
targetVersion = await this.documentVersionRepository.query().findOne({ document_id: documentId, version });
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.findOne({ document_id: documentId, version });
|
||||
|
||||
if (!targetVersion) {
|
||||
throw new NotFoundException(`Document version not found: ${version}`);
|
||||
}
|
||||
} else {
|
||||
targetVersion = await this.documentVersionRepository.query()
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.where({ document_id: documentId })
|
||||
.orderBy('version', 'DESC')
|
||||
.first();
|
||||
@@ -285,7 +313,11 @@ export class DocumentsService {
|
||||
return { url, expiresAt };
|
||||
}
|
||||
|
||||
async getFileContent(documentId: string, version?: number, userId?: string): Promise<{
|
||||
async getFileContent(
|
||||
documentId: string,
|
||||
version?: number,
|
||||
userId?: string,
|
||||
): Promise<{
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
@@ -299,13 +331,16 @@ export class DocumentsService {
|
||||
let targetVersion: DocumentVersion;
|
||||
|
||||
if (version) {
|
||||
targetVersion = await this.documentVersionRepository.query().findOne({ document_id: documentId, version });
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.findOne({ document_id: documentId, version });
|
||||
|
||||
if (!targetVersion) {
|
||||
throw new NotFoundException(`Document version not found: ${version}`);
|
||||
}
|
||||
} else {
|
||||
targetVersion = await this.documentVersionRepository.query()
|
||||
targetVersion = await this.documentVersionRepository
|
||||
.query()
|
||||
.where({ document_id: documentId })
|
||||
.orderBy('version', 'DESC')
|
||||
.first();
|
||||
@@ -316,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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ export class DocumentResponseDto {
|
||||
})
|
||||
fileHash?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'File size in bytes',
|
||||
required: false,
|
||||
})
|
||||
fileSize?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'MIME type of the file',
|
||||
required: false,
|
||||
})
|
||||
mimeType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'MinIO bucket name',
|
||||
})
|
||||
|
||||
@@ -2,16 +2,19 @@ import { IsString, IsIn, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export const ALLOWED_DOC_TYPES = [
|
||||
'FIRE_SAFETY_CERTIFICATE',
|
||||
'BUILDING_PLAN',
|
||||
'PROPERTY_OWNERSHIP',
|
||||
'INSPECTION_REPORT',
|
||||
'POLLUTION_CERTIFICATE',
|
||||
'ELECTRICAL_SAFETY_CERTIFICATE',
|
||||
'STRUCTURAL_STABILITY_CERTIFICATE',
|
||||
'IDENTITY_PROOF',
|
||||
'FLOOR_PLAN',
|
||||
'PHOTOGRAPH',
|
||||
'ID_PROOF',
|
||||
'ADDRESS_PROOF',
|
||||
'NOC',
|
||||
'LICENSE_COPY',
|
||||
'OTHER',
|
||||
'FIRE_SAFETY',
|
||||
'HEALTH_CERT',
|
||||
'TAX_CLEARANCE',
|
||||
'SITE_PLAN',
|
||||
'BUILDING_PERMIT',
|
||||
'BUSINESS_LICENSE',
|
||||
];
|
||||
|
||||
export class UploadDocumentDto {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as Minio from 'minio';
|
||||
|
||||
@@ -16,10 +21,7 @@ export class MinioService {
|
||||
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin');
|
||||
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin');
|
||||
|
||||
this.defaultBucket = this.configService.get<string>(
|
||||
'MINIO_BUCKET',
|
||||
'goa-gel-documents',
|
||||
);
|
||||
this.defaultBucket = this.configService.get<string>('MINIO_BUCKET', 'goa-gel-documents');
|
||||
this.region = this.configService.get<string>('MINIO_REGION', 'us-east-1');
|
||||
|
||||
this.minioClient = new Minio.Client({
|
||||
@@ -31,9 +33,7 @@ export class MinioService {
|
||||
region: this.region,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`MinIO client initialized: ${endPoint}:${port}, bucket: ${this.defaultBucket}`,
|
||||
);
|
||||
this.logger.log(`MinIO client initialized: ${endPoint}:${port}, bucket: ${this.defaultBucket}`);
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
@@ -61,18 +61,32 @@ export class MinioService {
|
||||
}
|
||||
}
|
||||
|
||||
async getSignedUrl(
|
||||
bucket: string,
|
||||
path: string,
|
||||
expiresIn: number = 3600,
|
||||
): Promise<string> {
|
||||
async getSignedUrl(bucket: string, path: string, expiresIn: number = 3600): Promise<string> {
|
||||
this.logger.debug(`Generating signed URL for: ${bucket}/${path}`);
|
||||
|
||||
try {
|
||||
// Verify file exists before generating URL
|
||||
try {
|
||||
await this.minioClient.statObject(bucket, path);
|
||||
} catch (statError: any) {
|
||||
if (
|
||||
statError.code === 'NotFound' ||
|
||||
statError.message?.includes('Not Found') ||
|
||||
statError.code === 'NoSuchKey'
|
||||
) {
|
||||
this.logger.error(`File not found for signed URL: ${bucket}/${path}`);
|
||||
throw new NotFoundException(`File not found in storage: ${path}`);
|
||||
}
|
||||
throw statError;
|
||||
}
|
||||
|
||||
const url = await this.minioClient.presignedGetObject(bucket, path, expiresIn);
|
||||
this.logger.debug(`Signed URL generated successfully`);
|
||||
return url;
|
||||
} catch (error: any) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Failed to generate signed URL: ${error.message}`);
|
||||
throw new InternalServerErrorException('Failed to generate download URL');
|
||||
}
|
||||
@@ -114,6 +128,14 @@ export class MinioService {
|
||||
const stat = await this.minioClient.statObject(bucket, path);
|
||||
return stat;
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.code === 'NotFound' ||
|
||||
error.message?.includes('Not Found') ||
|
||||
error.code === 'NoSuchKey'
|
||||
) {
|
||||
this.logger.error(`File not found: ${bucket}/${path}`);
|
||||
throw new NotFoundException(`File not found in storage: ${path}`);
|
||||
}
|
||||
this.logger.error(`Failed to get file metadata: ${error.message}`);
|
||||
throw new InternalServerErrorException('Failed to retrieve file metadata');
|
||||
}
|
||||
@@ -123,16 +145,45 @@ export class MinioService {
|
||||
this.logger.debug(`Fetching file from MinIO: ${bucket}/${path}`);
|
||||
|
||||
try {
|
||||
// First check if the bucket exists
|
||||
const bucketExists = await this.minioClient.bucketExists(bucket);
|
||||
if (!bucketExists) {
|
||||
this.logger.error(`Bucket does not exist: ${bucket}`);
|
||||
throw new NotFoundException(`Storage bucket not found: ${bucket}`);
|
||||
}
|
||||
|
||||
// Check if the file exists before trying to retrieve it
|
||||
try {
|
||||
await this.minioClient.statObject(bucket, path);
|
||||
} catch (statError: any) {
|
||||
if (
|
||||
statError.code === 'NotFound' ||
|
||||
statError.message?.includes('Not Found') ||
|
||||
statError.code === 'NoSuchKey'
|
||||
) {
|
||||
this.logger.error(`File not found in storage: ${bucket}/${path}`);
|
||||
throw new NotFoundException(`File not found in storage: ${path}`);
|
||||
}
|
||||
throw statError;
|
||||
}
|
||||
|
||||
const dataStream = await this.minioClient.getObject(bucket, path);
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
dataStream.on('data', (chunk) => chunks.push(chunk));
|
||||
dataStream.on('data', chunk => chunks.push(chunk));
|
||||
dataStream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
dataStream.on('error', reject);
|
||||
dataStream.on('error', err => {
|
||||
this.logger.error(`Stream error while fetching file: ${err.message}`);
|
||||
reject(new InternalServerErrorException('Failed to retrieve file from storage'));
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to get file: ${error.message}`);
|
||||
// Re-throw NotFoundException as-is
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Failed to get file: ${error.message}`, error.stack);
|
||||
throw new InternalServerErrorException('Failed to retrieve file from storage');
|
||||
}
|
||||
}
|
||||
@@ -163,4 +214,39 @@ export class MinioService {
|
||||
getDefaultBucket(): string {
|
||||
return this.defaultBucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MinIO service is healthy and can be reached
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
try {
|
||||
// Try to list buckets as a health check
|
||||
await this.minioClient.listBuckets();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`MinIO health check failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific file exists in storage
|
||||
*/
|
||||
async fileExists(bucket: string, path: string): Promise<boolean> {
|
||||
try {
|
||||
await this.minioClient.statObject(bucket, path);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.code === 'NotFound' ||
|
||||
error.message?.includes('Not Found') ||
|
||||
error.code === 'NoSuchKey'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// For other errors, log and return false
|
||||
this.logger.error(`Error checking file existence: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { Controller, Get, Inject, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { KNEX_CONNECTION } from '../../database/database.module';
|
||||
import type Knex from 'knex';
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { IsString, IsEnum, IsObject, ValidateNested, IsUUID, IsOptional, ValidateIf, MinLength, Matches, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
IsUUID,
|
||||
IsOptional,
|
||||
ValidateIf,
|
||||
MinLength,
|
||||
Matches,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { RequestType } from '../enums/request-type.enum';
|
||||
|
||||
@@ -82,7 +82,7 @@ export class RequestsController {
|
||||
this.logger.debug(`[${correlationId}] Creating new request for user: ${user.email}`);
|
||||
|
||||
// Only citizens/applicants can create requests (CITIZEN is the database role name)
|
||||
if (user.role !== UserRole.APPLICANT && user.role !== 'CITIZEN' as any) {
|
||||
if (user.role !== UserRole.APPLICANT && user.role !== ('CITIZEN' as any)) {
|
||||
throw new ForbiddenException('Only citizens can create license requests');
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export class RequestsController {
|
||||
this.logger.debug(`[${correlationId}] Fetching license requests`);
|
||||
|
||||
// Citizens can only see their own requests
|
||||
if (user.role === UserRole.APPLICANT || user.role === 'CITIZEN' as any) {
|
||||
if (user.role === UserRole.APPLICANT || user.role === ('CITIZEN' as any)) {
|
||||
const applicant = await this.applicantModel.query().findOne({ email: user.email });
|
||||
if (!applicant) {
|
||||
throw new BadRequestException('No applicant profile found for user');
|
||||
@@ -166,15 +166,15 @@ export class RequestsController {
|
||||
query.departmentCode = user.departmentCode;
|
||||
}
|
||||
|
||||
const { results, total } = await this.requestsService.findAll(query);
|
||||
const { data, total } = await this.requestsService.findAll(query);
|
||||
const totalPages = Math.ceil(total / query.limit);
|
||||
return {
|
||||
data: results.map((r) => this.mapToResponseDto(r)),
|
||||
meta: {
|
||||
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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -19,9 +19,7 @@ export class WebhooksService {
|
||||
|
||||
async register(departmentId: string, dto: CreateWebhookDto): Promise<Webhook> {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Registering webhook for department: ${departmentId}, URL: ${dto.url}`,
|
||||
);
|
||||
this.logger.debug(`Registering webhook for department: ${departmentId}, URL: ${dto.url}`);
|
||||
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
@@ -43,7 +41,8 @@ export class WebhooksService {
|
||||
|
||||
async findAll(departmentId: string): Promise<Webhook[]> {
|
||||
try {
|
||||
return await this.webhookRepository.query()
|
||||
return await this.webhookRepository
|
||||
.query()
|
||||
.where({ departmentId })
|
||||
.orderBy('created_at', 'DESC');
|
||||
} catch (error) {
|
||||
@@ -52,6 +51,17 @@ export class WebhooksService {
|
||||
}
|
||||
}
|
||||
|
||||
async findAllPaginated(pagination: PaginationDto): Promise<PaginatedResult<Webhook>> {
|
||||
try {
|
||||
const query = this.webhookRepository.query().orderBy('created_at', 'DESC');
|
||||
|
||||
return await paginate(query, pagination.page, pagination.limit);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find all webhooks', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Webhook> {
|
||||
try {
|
||||
const webhook = await this.webhookRepository.query().findById(id);
|
||||
@@ -161,11 +171,15 @@ export class WebhooksService {
|
||||
}
|
||||
}
|
||||
|
||||
async getLogs(webhookId: string, pagination: PaginationDto): Promise<PaginatedResult<WebhookLog>> {
|
||||
async getLogs(
|
||||
webhookId: string,
|
||||
pagination: PaginationDto,
|
||||
): Promise<PaginatedResult<WebhookLog>> {
|
||||
try {
|
||||
await this.findById(webhookId);
|
||||
|
||||
const query = this.webhookLogRepository.query()
|
||||
const query = this.webhookLogRepository
|
||||
.query()
|
||||
.where({ webhookId })
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
@@ -183,10 +197,7 @@ export class WebhooksService {
|
||||
|
||||
verifySignature(payload: object, signature: string, secret: string): boolean {
|
||||
const expectedSignature = this.generateSignature(payload, secret);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||
}
|
||||
|
||||
async logWebhookAttempt(
|
||||
|
||||
@@ -40,6 +40,31 @@ export class WebhooksController {
|
||||
|
||||
constructor(private readonly webhooksService: WebhooksService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all webhooks',
|
||||
description: 'Get all webhook subscriptions with pagination',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 20)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Paginated list of all webhooks',
|
||||
})
|
||||
async findAllWebhooks(@Query() pagination: PaginationDto) {
|
||||
return this.webhooksService.findAllPaginated(pagination);
|
||||
}
|
||||
|
||||
@Post(':departmentId')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
@@ -54,10 +79,7 @@ export class WebhooksController {
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid webhook data' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async register(
|
||||
@Param('departmentId') departmentId: string,
|
||||
@Body() dto: CreateWebhookDto,
|
||||
) {
|
||||
async register(@Param('departmentId') departmentId: string, @Body() dto: CreateWebhookDto) {
|
||||
this.logger.debug(`Registering webhook for department: ${departmentId}`);
|
||||
return this.webhooksService.register(departmentId, dto);
|
||||
}
|
||||
@@ -148,14 +170,21 @@ export class WebhooksController {
|
||||
description: 'Get paginated delivery logs for a specific webhook',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Webhook ID (UUID)' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Page number (default: 1)',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Items per page (default: 20)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Webhook delivery logs' })
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||
async getLogs(
|
||||
@Param('id') id: string,
|
||||
@Query() pagination: PaginationDto,
|
||||
) {
|
||||
async getLogs(@Param('id') id: string, @Query() pagination: PaginationDto) {
|
||||
return this.webhooksService.getLogs(id, pagination);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { WorkflowStageDto } from './workflow-stage.dto';
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { WorkflowStageDto } from './workflow-stage.dto';
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsOptional,
|
||||
} from 'class-validator';
|
||||
import { IsString, IsNumber, IsEnum, IsArray, ValidateNested, IsOptional } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ExecutionType } from '../enums/execution-type.enum';
|
||||
import { CompletionCriteria } from '../enums/completion-criteria.enum';
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
DocumentUpdateResult,
|
||||
StageCompletionCheck,
|
||||
} from '../interfaces/workflow-process-result.interface';
|
||||
import {
|
||||
WorkflowDefinition,
|
||||
WorkflowStage,
|
||||
} from '../interfaces/workflow-definition.interface';
|
||||
import { WorkflowDefinition, WorkflowStage } from '../interfaces/workflow-definition.interface';
|
||||
import { WorkflowAction } from '../enums/workflow-action.enum';
|
||||
import { CompletionCriteria } from '../enums/completion-criteria.enum';
|
||||
import { RejectionHandling } from '../enums/rejection-handling.enum';
|
||||
@@ -35,17 +32,14 @@ export class WorkflowExecutorService {
|
||||
/**
|
||||
* Initialize workflow state for a new request
|
||||
*/
|
||||
async initializeWorkflow(
|
||||
requestId: string,
|
||||
workflowId: string,
|
||||
): Promise<WorkflowState> {
|
||||
async initializeWorkflow(requestId: string, workflowId: string): Promise<WorkflowState> {
|
||||
const workflow = await this.workflowRepository.query().findById(workflowId);
|
||||
|
||||
if (!workflow || !workflow.isActive) {
|
||||
throw new NotFoundException(`Active workflow ${workflowId} not found`);
|
||||
}
|
||||
|
||||
const definition = (workflow.definition as any) as WorkflowDefinition;
|
||||
const definition = workflow.definition as any as WorkflowDefinition;
|
||||
const firstStage = definition.stages[0];
|
||||
|
||||
if (!firstStage) {
|
||||
@@ -53,7 +47,7 @@ export class WorkflowExecutorService {
|
||||
}
|
||||
|
||||
const pendingApprovals: PendingApproval[] = (firstStage.requiredApprovals || []).map(
|
||||
(approval) => ({
|
||||
approval => ({
|
||||
departmentCode: approval.departmentCode,
|
||||
departmentName: approval.departmentName,
|
||||
approvalId: '', // Will be populated when approval records are created
|
||||
@@ -88,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);
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
|
||||
|
||||
import { Workflow } from '../../../database/models/workflow.model';
|
||||
import { CreateWorkflowDto } from '../dto/create-workflow.dto';
|
||||
@@ -40,9 +35,7 @@ export class WorkflowsService {
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new BadRequestException(
|
||||
`Workflow validation failed: ${validation.errors.join(', ')}`,
|
||||
);
|
||||
throw new BadRequestException(`Workflow validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
const definition: WorkflowDefinition = {
|
||||
@@ -74,7 +67,7 @@ export class WorkflowsService {
|
||||
const query = this.workflowRepository.query();
|
||||
|
||||
if (isActive !== undefined) {
|
||||
query.where({ isActive });
|
||||
query.where({ is_active: isActive });
|
||||
}
|
||||
|
||||
return query.orderBy('created_at', 'DESC');
|
||||
@@ -97,14 +90,13 @@ export class WorkflowsService {
|
||||
* Get workflow by type
|
||||
*/
|
||||
async findByType(workflowType: string): Promise<Workflow> {
|
||||
const workflow = await this.workflowRepository.query()
|
||||
const workflow = await this.workflowRepository
|
||||
.query()
|
||||
.findOne({ workflowType, isActive: true })
|
||||
.orderBy('version', 'DESC');
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(
|
||||
`No active workflow found for type ${workflowType}`,
|
||||
);
|
||||
throw new NotFoundException(`No active workflow found for type ${workflowType}`);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
@@ -205,9 +197,7 @@ export class WorkflowsService {
|
||||
}
|
||||
|
||||
if (stage.stageOrder !== index) {
|
||||
warnings.push(
|
||||
`Stage ${index} has stageOrder ${stage.stageOrder}, expected ${index}`,
|
||||
);
|
||||
warnings.push(`Stage ${index} has stageOrder ${stage.stageOrder}, expected ${index}`);
|
||||
}
|
||||
|
||||
if (!stage.requiredApprovals || stage.requiredApprovals.length === 0) {
|
||||
@@ -217,26 +207,16 @@ export class WorkflowsService {
|
||||
// Validate completion criteria
|
||||
if (stage.completionCriteria === CompletionCriteria.THRESHOLD) {
|
||||
if (!stage.threshold || stage.threshold < 1) {
|
||||
errors.push(
|
||||
`Stage ${stage.stageId} uses THRESHOLD but no valid threshold is set`,
|
||||
);
|
||||
errors.push(`Stage ${stage.stageId} uses THRESHOLD but no valid threshold is set`);
|
||||
}
|
||||
|
||||
if (
|
||||
stage.threshold &&
|
||||
stage.threshold > stage.requiredApprovals.length
|
||||
) {
|
||||
errors.push(
|
||||
`Stage ${stage.stageId} threshold exceeds number of required approvals`,
|
||||
);
|
||||
if (stage.threshold && stage.threshold > stage.requiredApprovals.length) {
|
||||
errors.push(`Stage ${stage.stageId} threshold exceeds number of required approvals`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejection handling
|
||||
if (
|
||||
stage.rejectionHandling === 'ESCALATE' &&
|
||||
!stage.escalationDepartment
|
||||
) {
|
||||
if (stage.rejectionHandling === 'ESCALATE' && !stage.escalationDepartment) {
|
||||
warnings.push(
|
||||
`Stage ${stage.stageId} uses ESCALATE but no escalationDepartment is configured`,
|
||||
);
|
||||
@@ -262,11 +242,11 @@ export class WorkflowsService {
|
||||
workflowId: workflow.id,
|
||||
workflowType: workflow.workflowType,
|
||||
name: workflow.name,
|
||||
stages: definition.stages.map((stage) => ({
|
||||
stages: definition.stages.map(stage => ({
|
||||
stageId: stage.stageId,
|
||||
stageName: stage.stageName,
|
||||
stageOrder: stage.stageOrder,
|
||||
departments: stage.requiredApprovals.map((ra) => ra.departmentCode),
|
||||
departments: stage.requiredApprovals.map(ra => ra.departmentCode),
|
||||
executionType: stage.executionType,
|
||||
completionCriteria: stage.completionCriteria,
|
||||
})),
|
||||
|
||||
@@ -66,7 +66,8 @@ export class WorkflowsController {
|
||||
@ApiResponse({ status: 200, description: 'List of workflows' })
|
||||
async findAll(@Query('isActive') isActive?: string) {
|
||||
const active = isActive !== undefined ? isActive === 'true' : undefined;
|
||||
return this.workflowsService.findAll(active);
|
||||
const workflows = await this.workflowsService.findAll(active);
|
||||
return { data: workflows, total: workflows.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -5,9 +5,7 @@ import { WorkflowExecutorService } from './services/workflow-executor.service';
|
||||
import { ApprovalsModule } from '../approvals/approvals.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApprovalsModule,
|
||||
],
|
||||
imports: [ApprovalsModule],
|
||||
controllers: [WorkflowsController],
|
||||
providers: [WorkflowsService, WorkflowExecutorService],
|
||||
exports: [WorkflowsService, WorkflowExecutorService],
|
||||
|
||||
Reference in New Issue
Block a user