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

Goa GEL Platform

-

Blockchain-Powered e-Licensing System

-

Transparent • Secure • Efficient

-
-
- - -
-

The Challenge

-
-
-
📋
-

Manual Processes

-

Paper-based applications causing delays and inefficiencies

-
-
-
🔍
-

Lack of Transparency

-

Citizens unable to track application status in real-time

-
-
-
🏢
-

Siloed Departments

-

No unified system for multi-department approvals

-
-
-
📄
-

Document Tampering

-

No mechanism to verify authenticity of issued licenses

-
-
-
- - -
-

Our Solution

-

A unified blockchain-powered platform for government e-licensing

-
-
-
🌐
-

Digital Portal

-

Single window for all license applications with role-based access

-
-
-
⛓️
-

Blockchain Integration

-

Immutable records for approvals, documents, and issued licenses

-
-
-
🔄
-

Automated Workflows

-

Configurable multi-stage approval processes

-
-
-
🔐
-

NFT Licenses

-

Tamper-proof digital certificates as blockchain tokens

-
-
-
- - -
-

System Architecture

-
-
-
Angular Frontend
-
-
↓ ↑
-
-
NestJS API Server
-
-
↓ ↑
-
-
PostgreSQL
-
Redis
-
MinIO
-
Hyperledger Besu
-
-
-
-
-

PostgreSQL

-

Primary Database

-
-
-

Redis

-

Caching & Sessions

-
-
-

MinIO

-

Document Storage

-
-
-

Besu

-

Blockchain Network

-
-
-
- - -
-

Technology Stack

-
-
-

Frontend

-
- Angular 21 - Angular Material - TailwindCSS - RxJS - Playwright -
- -

Backend

-
- NestJS - TypeScript - Knex ORM - JWT Auth - Swagger -
-
-
-

Blockchain

-
- Hyperledger Besu - Solidity - Hardhat - ethers.js - Blockscout -
- -

Infrastructure

-
- Docker - PostgreSQL - Redis - MinIO - Nginx -
-
-
-
- - -
-

License Application Workflow

-
-
-
👤
-

Citizen

-

Submits Application

-
- -
-
📝
-

Document Upload

-

Hash stored on chain

-
- -
-
🏛️
-

Dept. Review

-

Multi-stage approval

-
- -
-
-

Approval

-

Recorded on blockchain

-
- -
-
🎫
-

NFT License

-

Issued as token

-
-
-
-

Blockchain Records at Each Step

- -
-
- - -
-

Blockchain Smart Contracts

- - - - - - - - - - - - - - - - - - - - - - - - - - -
ContractPurposeKey Functions
LicenseNFTMint licenses as NFT certificatesmintLicense(), verifyLicense(), revokeLicense()
DocumentChainStore document hashesregisterDocument(), verifyDocument()
ApprovalManagerRecord approval decisionsrecordApproval(), getApprovalHistory()
WorkflowRegistryManage workflow definitionsregisterWorkflow(), getWorkflowStages()
-
-

Network: Hyperledger Besu (IBFT 2.0)

-

Private permissioned network with ~5 second block times and Proof of Authority consensus

-
-
- - -
-

User Roles & Dashboards

-
-
-
👨‍💼
-

Administrator

-
    -
  • Manage departments
  • -
  • Configure workflows
  • -
  • View audit logs
  • -
  • Platform analytics
  • -
  • User management
  • -
-
-
-
🏢
-

Department

-
    -
  • Review applications
  • -
  • Approve/reject requests
  • -
  • Request documents
  • -
  • View assigned queue
  • -
  • Track department KPIs
  • -
-
-
-
👤
-

Citizen

-
    -
  • Submit applications
  • -
  • Upload documents
  • -
  • Track status
  • -
  • View timeline
  • -
  • Download licenses
  • -
-
-
-
- - -
-

Key Features

-
-
-

Visual Workflow Builder

-

Drag-and-drop interface to create multi-stage, multi-department approval workflows

-
-
-

Real-time Blockchain Explorer

-

Live view of blocks, transactions, and network health integrated in dashboard

-
-
-

Document Integrity

-

SHA-256 hashes stored on blockchain for tamper-proof verification

-
-
-

Comprehensive Audit Trail

-

Every action logged with user, timestamp, and correlation IDs

-
-
-

Webhook Notifications

-

Real-time event notifications to external systems

-
-
-

API-First Design

-

RESTful API with Swagger documentation for integrations

-
-
-
- - -
-

Security & Compliance

-
-
-
🔐
-

Authentication

-
    -
  • JWT-based authentication
  • -
  • Role-based access control
  • -
  • API key auth for departments
  • -
  • Session management with Redis
  • -
-
-
-
⛓️
-

Blockchain Security

-
    -
  • Private permissioned network
  • -
  • IBFT 2.0 consensus
  • -
  • Immutable audit trail
  • -
  • Cryptographic verification
  • -
-
-
-
📊
-

Data Protection

-
    -
  • Encrypted storage
  • -
  • Secure file handling
  • -
  • Input validation
  • -
  • SQL injection prevention
  • -
-
-
-
📝
-

Audit & Compliance

-
    -
  • Complete action logging
  • -
  • Correlation ID tracking
  • -
  • Exportable audit reports
  • -
  • Blockchain verification
  • -
-
-
-
- - -
-

Platform Statistics

-
-
-

266

-

API Tests Passing

-
-
-

37

-

E2E Tests

-
-
-

4

-

Smart Contracts

-
-
-

441

-

Source Files

-
-
-

100K+

-

Lines of Code

-
-
-

9

-

Docker Services

-
-
-
- - -
-
-

Thank You

-

Goa GEL Blockchain e-Licensing Platform

-
-

Demo URLs

-

Frontend: http://localhost:4200

-

API Docs: http://localhost:3001/api/docs

-

Blockchain Explorer: http://localhost:4000

-
-
-
- - - - -
- 1 / 12 -
- - - - diff --git a/START_HERE_AFTER_REBOOT.md b/START_HERE_AFTER_REBOOT.md deleted file mode 100644 index c2e8097..0000000 --- a/START_HERE_AFTER_REBOOT.md +++ /dev/null @@ -1,99 +0,0 @@ -# 🔄 After System Reboot - Start Here - -## Session Backups Location - -All session backups have been moved to: - -``` -/Users/Mahi-Workspace/Workspace/Claude/Goa-GEL/session-backups/ -``` - -## Quick Access - -### Backend Session (API Test Fixes) -```bash -cd session-backups/backend -cat README_AFTER_REBOOT.md - -# Or run automated script: -./COMMANDS_AFTER_REBOOT.sh -``` - -### Frontend Session -```bash -cd session-backups/frontend -# Frontend agent will store session state here -``` - -## Why Separate Directories? - -- **Backend agent:** Working on API test fixes -- **Frontend agent:** Working on frontend app (parallel work) -- **No conflicts:** Each agent has isolated session storage -- **Shared Docker:** Services are shared, code is isolated - -## Structure - -``` -Goa-GEL/ -├── backend/ ← Backend source code -├── frontend/ ← Frontend source code -├── session-backups/ -│ ├── backend/ ← Backend session state & commands -│ │ ├── README_AFTER_REBOOT.md -│ │ ├── COMMANDS_AFTER_REBOOT.sh ← Run this! -│ │ ├── SESSION_STATE_BACKUP.md -│ │ └── ... (other backup files) -│ └── frontend/ ← Frontend session state -│ └── (frontend backups will go here) -└── START_HERE_AFTER_REBOOT.md ← This file -``` - -## Backend Status - -- ✅ **Progress:** 213/282 tests passing (75.5%) -- ✅ **Code:** All fixes completed -- ⏸️ **Waiting:** System reboot to fix Docker -- 🎯 **Expected:** 220+ tests after restart - -## What the Backend Script Does - -The `session-backups/backend/COMMANDS_AFTER_REBOOT.sh` script will: - -1. ✅ Check Docker is running -2. ✅ Restart **only API service** (not frontend) -3. ✅ Verify core services (postgres, redis, minio, api) -4. ✅ Run backend API tests -5. ✅ Save results and show summary - -**Frontend work is NOT affected** - only API is restarted. - -## After Reboot - -1. **Navigate to backend session:** - ```bash - cd /Users/Mahi-Workspace/Workspace/Claude/Goa-GEL/session-backups/backend - ``` - -2. **Run the script:** - ```bash - ./COMMANDS_AFTER_REBOOT.sh - ``` - -3. **Tell Claude:** "tests completed" or "continue" - -## Frontend Agent Note - -Frontend agent can store session backups in: -``` -/Users/Mahi-Workspace/Workspace/Claude/Goa-GEL/session-backups/frontend/ -``` - -This keeps frontend and backend work completely separate and conflict-free. - ---- - -**📂 Go to:** `session-backups/backend/` to resume backend work -**📂 Go to:** `session-backups/frontend/` for frontend work - -Both agents can work in parallel without conflicts! 🚀 diff --git a/backend/Dockerfile b/backend/Dockerfile index 814d76b..811d60d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/package.json b/backend/package.json index 55fca1b..c7dd653 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/db-reset.sh b/backend/scripts/db-reset.sh new file mode 100755 index 0000000..f7467e4 --- /dev/null +++ b/backend/scripts/db-reset.sh @@ -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" < { + 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 "" diff --git a/backend/scripts/docker-entrypoint.sh b/backend/scripts/docker-entrypoint.sh old mode 100644 new mode 100755 index 1b2d81b..18d523c --- a/backend/scripts/docker-entrypoint.sh +++ b/backend/scripts/docker-entrypoint.sh @@ -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 diff --git a/backend/scripts/health-check.sh b/backend/scripts/health-check.sh new file mode 100755 index 0000000..6ca11e9 --- /dev/null +++ b/backend/scripts/health-check.sh @@ -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 diff --git a/backend/scripts/init-blockchain.js b/backend/scripts/init-blockchain.js index ad29c84..6c4e12d 100644 --- a/backend/scripts/init-blockchain.js +++ b/backend/scripts/init-blockchain.js @@ -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}`; } } diff --git a/backend/scripts/init-db.sh b/backend/scripts/init-db.sh old mode 100644 new mode 100755 index 91ff152..a825cd8 --- a/backend/scripts/init-db.sh +++ b/backend/scripts/init-db.sh @@ -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" diff --git a/backend/scripts/seed-demo-applications.sh b/backend/scripts/seed-demo-applications.sh new file mode 100755 index 0000000..779365c --- /dev/null +++ b/backend/scripts/seed-demo-applications.sh @@ -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" <('NODE_ENV', 'development'); const isDevelopment = nodeEnv === 'development' || nodeEnv === 'test'; - return [{ - ttl: isDevelopment ? 1000 : configService.get('RATE_LIMIT_TTL', 60) * 1000, - limit: isDevelopment ? 10000 : configService.get('RATE_LIMIT_GLOBAL', 100), - }]; + return [ + { + ttl: isDevelopment ? 1000 : configService.get('RATE_LIMIT_TTL', 60) * 1000, + limit: isDevelopment ? 10000 : configService.get('RATE_LIMIT_GLOBAL', 100), + }, + ]; }, }), diff --git a/backend/src/common/constants/index.ts b/backend/src/common/constants/index.ts index cec2627..b17b2db 100644 --- a/backend/src/common/constants/index.ts +++ b/backend/src/common/constants/index.ts @@ -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', diff --git a/backend/src/common/decorators/correlation-id.decorator.ts b/backend/src/common/decorators/correlation-id.decorator.ts index 314c95b..8482b30 100644 --- a/backend/src/common/decorators/correlation-id.decorator.ts +++ b/backend/src/common/decorators/correlation-id.decorator.ts @@ -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(); +}); diff --git a/backend/src/common/filters/all-exceptions.filter.ts b/backend/src/common/filters/all-exceptions.filter.ts index e8622ac..a4450e9 100644 --- a/backend/src/common/filters/all-exceptions.filter.ts +++ b/backend/src/common/filters/all-exceptions.filter.ts @@ -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; diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index bdec9e2..2acaec9 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -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 = { diff --git a/backend/src/common/guards/api-key.guard.ts b/backend/src/common/guards/api-key.guard.ts index 2966521..495f502 100644 --- a/backend/src/common/guards/api-key.guard.ts +++ b/backend/src/common/guards/api-key.guard.ts @@ -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; } } diff --git a/backend/src/common/guards/roles.guard.ts b/backend/src/common/guards/roles.guard.ts index 6f32c2e..1957898 100644 --- a/backend/src/common/guards/roles.guard.ts +++ b/backend/src/common/guards/roles.guard.ts @@ -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({ diff --git a/backend/src/common/interceptors/correlation-id.interceptor.ts b/backend/src/common/interceptors/correlation-id.interceptor.ts index a845177..d1a6888 100644 --- a/backend/src/common/interceptors/correlation-id.interceptor.ts +++ b/backend/src/common/interceptors/correlation-id.interceptor.ts @@ -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(); const correlationId = (request.headers[CORRELATION_ID_HEADER] as string) || uuidv4(); - + request.headers[CORRELATION_ID_HEADER] = correlationId; response.setHeader(CORRELATION_ID_HEADER, correlationId); diff --git a/backend/src/common/interceptors/logging.interceptor.ts b/backend/src/common/interceptors/logging.interceptor.ts index bf19d83..1d99d7a 100644 --- a/backend/src/common/interceptors/logging.interceptor.ts +++ b/backend/src/common/interceptors/logging.interceptor.ts @@ -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(); const response = context.switchToHttp().getResponse(); 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(); diff --git a/backend/src/common/interceptors/timeout.interceptor.ts b/backend/src/common/interceptors/timeout.interceptor.ts index 597b76a..0da9095 100644 --- a/backend/src/common/interceptors/timeout.interceptor.ts +++ b/backend/src/common/interceptors/timeout.interceptor.ts @@ -11,8 +11,6 @@ import { timeout } from 'rxjs/operators'; @Injectable() export class TimeoutInterceptor implements NestInterceptor { intercept(_context: ExecutionContext, next: CallHandler): Observable { - return next.handle().pipe( - timeout(30000), - ); + return next.handle().pipe(timeout(30000)); } } diff --git a/backend/src/common/interceptors/transform.interceptor.ts b/backend/src/common/interceptors/transform.interceptor.ts index 9901d32..b859924 100644 --- a/backend/src/common/interceptors/transform.interceptor.ts +++ b/backend/src/common/interceptors/transform.interceptor.ts @@ -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'; diff --git a/backend/src/common/pipes/validation.pipe.ts b/backend/src/common/pipes/validation.pipe.ts index 01d4c42..0552507 100644 --- a/backend/src/common/pipes/validation.pipe.ts +++ b/backend/src/common/pipes/validation.pipe.ts @@ -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, diff --git a/backend/src/common/utils/crypto.util.ts b/backend/src/common/utils/crypto.util.ts index ea23ff8..f5f3f2a 100644 --- a/backend/src/common/utils/crypto.util.ts +++ b/backend/src/common/utils/crypto.util.ts @@ -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(); diff --git a/backend/src/common/utils/date.util.ts b/backend/src/common/utils/date.util.ts index 5749089..ba20fd1 100644 --- a/backend/src/common/utils/date.util.ts +++ b/backend/src/common/utils/date.util.ts @@ -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 { diff --git a/backend/src/common/utils/pagination.util.ts b/backend/src/common/utils/pagination.util.ts index a577435..ee32ace 100644 --- a/backend/src/common/utils/pagination.util.ts +++ b/backend/src/common/utils/pagination.util.ts @@ -1,7 +1,7 @@ import { QueryBuilder } from 'objection'; export interface PaginatedResult { - results: T[]; + data: T[]; total: number; } @@ -19,7 +19,7 @@ export async function paginate( const l = limit > 0 ? limit : 10; const { results, total } = await query.page(p, l); - return { results, total }; + return { data: results, total }; } -export { QueryBuilder }; \ No newline at end of file +export { QueryBuilder }; diff --git a/backend/src/common/utils/request-number.util.ts b/backend/src/common/utils/request-number.util.ts index f1f66ad..b6c73cf 100644 --- a/backend/src/common/utils/request-number.util.ts +++ b/backend/src/common/utils/request-number.util.ts @@ -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), diff --git a/backend/src/config/app.config.ts b/backend/src/config/app.config.ts index 9e88e09..584de16 100644 --- a/backend/src/config/app.config.ts +++ b/backend/src/config/app.config.ts @@ -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), }); diff --git a/backend/src/config/minio.config.ts b/backend/src/config/minio.config.ts index 9d720e0..5ef9557 100644 --- a/backend/src/config/minio.config.ts +++ b/backend/src/config/minio.config.ts @@ -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 { diff --git a/backend/src/database/database.module.ts b/backend/src/database/database.module.ts index a55dcf6..a54bf29 100644 --- a/backend/src/database/database.module.ts +++ b/backend/src/database/database.module.ts @@ -22,9 +22,7 @@ export const KNEX_CONNECTION = 'KNEX_CONNECTION'; database: configService.get('database.database'), user: configService.get('database.username'), password: configService.get('database.password'), - ssl: configService.get('database.ssl') - ? { rejectUnauthorized: false } - : false, + ssl: configService.get('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 { if (this.knex) { diff --git a/backend/src/database/knexfile.ts b/backend/src/database/knexfile.ts index 025ec14..c77b594 100644 --- a/backend/src/database/knexfile.ts +++ b/backend/src/database/knexfile.ts @@ -78,4 +78,4 @@ const knexConfig: { [key: string]: Knex.Config } = { }, }; -export default knexConfig; \ No newline at end of file +export default knexConfig; diff --git a/backend/src/database/migrations/20240101000000_initial_schema.ts b/backend/src/database/migrations/20240101000000_initial_schema.ts index c582245..db48558 100644 --- a/backend/src/database/migrations/20240101000000_initial_schema.ts +++ b/backend/src/database/migrations/20240101000000_initial_schema.ts @@ -5,7 +5,7 @@ export async function up(knex: Knex): Promise { 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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(); diff --git a/backend/src/database/migrations/20240201000000_add_users_wallets_events_logs.ts b/backend/src/database/migrations/20240201000000_add_users_wallets_events_logs.ts index aa8eeb6..084cc50 100644 --- a/backend/src/database/migrations/20240201000000_add_users_wallets_events_logs.ts +++ b/backend/src/database/migrations/20240201000000_add_users_wallets_events_logs.ts @@ -2,7 +2,7 @@ import type { Knex } from 'knex'; export async function up(knex: Knex): Promise { // 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 { }); // 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 { }); // 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 { }); // 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 { }); // 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 { export async function down(knex: Knex): Promise { // 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'); diff --git a/backend/src/database/models.module.ts b/backend/src/database/models.module.ts index a6d535d..1c405a8 100644 --- a/backend/src/database/models.module.ts +++ b/backend/src/database/models.module.ts @@ -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 {} diff --git a/backend/src/database/models/document-version.model.ts b/backend/src/database/models/document-version.model.ts index e602a1c..e1a0680 100644 --- a/backend/src/database/models/document-version.model.ts +++ b/backend/src/database/models/document-version.model.ts @@ -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' }, diff --git a/backend/src/database/models/license-request.model.ts b/backend/src/database/models/license-request.model.ts index 1b74543..407809f 100644 --- a/backend/src/database/models/license-request.model.ts +++ b/backend/src/database/models/license-request.model.ts @@ -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, diff --git a/backend/src/database/models/workflow-state.model.ts b/backend/src/database/models/workflow-state.model.ts index 06ed082..b64f2d5 100644 --- a/backend/src/database/models/workflow-state.model.ts +++ b/backend/src/database/models/workflow-state.model.ts @@ -19,4 +19,4 @@ export class WorkflowState extends BaseModel { }, }, }; -} \ No newline at end of file +} diff --git a/backend/src/database/seeds/001_initial_seed.ts b/backend/src/database/seeds/001_initial_seed.ts index 16aeab3..c390828 100644 --- a/backend/src/database/seeds/001_initial_seed.ts +++ b/backend/src/database/seeds/001_initial_seed.ts @@ -24,12 +24,58 @@ function generateWallet(): { address: string; encryptedPrivateKey: string } { }; } +// Helper to generate realistic dates spread over the last 30 days +function randomDate(daysAgo: number = 30): Date { + const now = new Date(); + const randomDays = Math.floor(Math.random() * daysAgo); + const randomHours = Math.floor(Math.random() * 24); + const randomMinutes = Math.floor(Math.random() * 60); + return new Date( + now.getTime() - + randomDays * 24 * 60 * 60 * 1000 - + randomHours * 60 * 60 * 1000 - + randomMinutes * 60 * 1000, + ); +} + +// Helper to generate sequential dates for workflow progression +function sequentialDate(baseDate: Date, hoursLater: number): Date { + return new Date(baseDate.getTime() + hoursLater * 60 * 60 * 1000); +} + +// Helper to generate fake blockchain transaction hash +function fakeTxHash(): string { + return '0x' + crypto.randomBytes(32).toString('hex'); +} + +// Helper to generate fake document hash +function fakeDocHash(): string { + return '0x' + crypto.randomBytes(32).toString('hex'); +} + export async function seed(knex: Knex): Promise { // Clear existing data (in reverse order of dependencies) - await knex('application_logs').del().catch(() => {}); - await knex('blockchain_events').del().catch(() => {}); - await knex('wallets').del().catch(() => {}); - await knex('users').del().catch(() => {}); + await knex('application_logs') + .del() + .catch(() => {}); + await knex('blockchain_events') + .del() + .catch(() => {}); + await knex('blockchain_transactions') + .del() + .catch(() => {}); + await knex('audit_logs') + .del() + .catch(() => {}); + await knex('webhook_logs') + .del() + .catch(() => {}); + await knex('wallets') + .del() + .catch(() => {}); + await knex('users') + .del() + .catch(() => {}); await knex('approvals').del(); await knex('workflow_states').del(); await knex('document_versions').del(); @@ -40,66 +86,109 @@ export async function seed(knex: Knex): Promise { await knex('departments').del(); await knex('applicants').del(); - // Generate wallets for departments - const fireDeptWallet = generateWallet(); + // ============================================ + // DEPARTMENTS (5 Goa Government Departments) + // ============================================ + const transportDeptWallet = generateWallet(); + const revenueDeptWallet = generateWallet(); + const policeDeptWallet = generateWallet(); const tourismDeptWallet = generateWallet(); - const municipalityWallet = generateWallet(); const healthDeptWallet = generateWallet(); + const fireDeptWallet = generateWallet(); + const municipalityWallet = generateWallet(); - // Create departments with IDs we can reference - const fireDeptId = uuidv4(); + const transportDeptId = uuidv4(); + const revenueDeptId = uuidv4(); + const policeDeptId = uuidv4(); const tourismDeptId = uuidv4(); - const municipalityId = uuidv4(); const healthDeptId = uuidv4(); + const fireDeptId = uuidv4(); + const municipalityId = uuidv4(); const departments = [ { - id: fireDeptId, - code: 'FIRE_DEPT', - name: 'Fire & Emergency Services Department', - wallet_address: fireDeptWallet.address, - api_key_hash: await bcrypt.hash('fire_api_key_123', 10), - api_secret_hash: await bcrypt.hash('fire_secret_456', 10), + id: transportDeptId, + code: 'TRANSPORT_DEPT', + name: 'Directorate of Transport, Goa', + wallet_address: transportDeptWallet.address, + api_key_hash: await bcrypt.hash('transport_api_key_123', 10), + api_secret_hash: await bcrypt.hash('transport_secret_456', 10), is_active: true, - description: 'Responsible for fire safety inspections and certifications', - contact_email: 'fire@goa.gov.in', - contact_phone: '+91-832-2222222', + description: 'Handles vehicle registration, driving licenses, and transport permits in Goa', + contact_email: 'transport@goa.gov.in', + contact_phone: '+91-832-2438801', + }, + { + id: revenueDeptId, + code: 'REVENUE_DEPT', + name: 'Directorate of Land Revenue, Goa', + wallet_address: revenueDeptWallet.address, + api_key_hash: await bcrypt.hash('revenue_api_key_123', 10), + api_secret_hash: await bcrypt.hash('revenue_secret_456', 10), + is_active: true, + description: 'Manages land records, property registration, and revenue collection', + contact_email: 'revenue@goa.gov.in', + contact_phone: '+91-832-2225566', + }, + { + id: policeDeptId, + code: 'POLICE_DEPT', + name: 'Goa Police Department', + wallet_address: policeDeptWallet.address, + api_key_hash: await bcrypt.hash('police_api_key_123', 10), + api_secret_hash: await bcrypt.hash('police_secret_456', 10), + is_active: true, + description: 'Law enforcement, character verification, and NOC issuance', + contact_email: 'dgp@goapolice.gov.in', + contact_phone: '+91-832-2418701', }, { id: tourismDeptId, code: 'TOURISM_DEPT', - name: 'Department of Tourism', + name: 'Goa Tourism Development Corporation', wallet_address: tourismDeptWallet.address, api_key_hash: await bcrypt.hash('tourism_api_key_123', 10), api_secret_hash: await bcrypt.hash('tourism_secret_456', 10), is_active: true, - description: 'Manages tourism licenses and hospitality registrations', + description: 'Promotes tourism, issues licenses for hotels, resorts, and tourism activities', contact_email: 'tourism@goa.gov.in', - contact_phone: '+91-832-3333333', - }, - { - id: municipalityId, - code: 'MUNICIPALITY', - name: 'Municipal Corporation of Panaji', - wallet_address: municipalityWallet.address, - api_key_hash: await bcrypt.hash('municipality_api_key_123', 10), - api_secret_hash: await bcrypt.hash('municipality_secret_456', 10), - is_active: true, - description: 'Local governance and building permits', - contact_email: 'municipality@goa.gov.in', - contact_phone: '+91-832-4444444', + contact_phone: '+91-832-2437132', }, { id: healthDeptId, code: 'HEALTH_DEPT', - name: 'Directorate of Health Services', + name: 'Directorate of Health Services, Goa', wallet_address: healthDeptWallet.address, api_key_hash: await bcrypt.hash('health_api_key_123', 10), api_secret_hash: await bcrypt.hash('health_secret_456', 10), is_active: true, - description: 'Health and sanitation inspections', + description: 'Health inspections, sanitation certificates, and medical licenses', contact_email: 'health@goa.gov.in', - contact_phone: '+91-832-5555555', + contact_phone: '+91-832-2225736', + }, + { + id: fireDeptId, + code: 'FIRE_DEPT', + name: 'Goa Fire & Emergency Services', + wallet_address: fireDeptWallet.address, + api_key_hash: await bcrypt.hash('fire_api_key_123', 10), + api_secret_hash: await bcrypt.hash('fire_secret_456', 10), + is_active: true, + description: 'Fire safety inspections and NOC issuance for commercial establishments', + contact_email: 'fire@goa.gov.in', + contact_phone: '+91-832-2222222', + }, + { + id: municipalityId, + code: 'MUNICIPALITY', + name: 'Corporation of the City of Panaji', + wallet_address: municipalityWallet.address, + api_key_hash: await bcrypt.hash('municipality_api_key_123', 10), + api_secret_hash: await bcrypt.hash('municipality_secret_456', 10), + is_active: true, + description: 'Local governance, building permits, trade licenses, and property tax', + contact_email: 'ccpanaji@goa.gov.in', + contact_phone: '+91-832-2426516', }, ]; @@ -107,10 +196,55 @@ export async function seed(knex: Knex): Promise { // Store department wallets const departmentWallets = [ - { ...fireDeptWallet, id: uuidv4(), owner_type: 'DEPARTMENT', owner_id: fireDeptId, is_active: true }, - { ...tourismDeptWallet, id: uuidv4(), owner_type: 'DEPARTMENT', owner_id: tourismDeptId, is_active: true }, - { ...municipalityWallet, id: uuidv4(), owner_type: 'DEPARTMENT', owner_id: municipalityId, is_active: true }, - { ...healthDeptWallet, id: uuidv4(), owner_type: 'DEPARTMENT', owner_id: healthDeptId, is_active: true }, + { + ...transportDeptWallet, + id: uuidv4(), + owner_type: 'DEPARTMENT', + owner_id: transportDeptId, + is_active: true, + }, + { + ...revenueDeptWallet, + id: uuidv4(), + owner_type: 'DEPARTMENT', + owner_id: revenueDeptId, + is_active: true, + }, + { + ...policeDeptWallet, + id: uuidv4(), + owner_type: 'DEPARTMENT', + owner_id: policeDeptId, + is_active: true, + }, + { + ...tourismDeptWallet, + id: uuidv4(), + owner_type: 'DEPARTMENT', + owner_id: tourismDeptId, + is_active: true, + }, + { + ...healthDeptWallet, + id: uuidv4(), + owner_type: 'DEPARTMENT', + owner_id: healthDeptId, + is_active: true, + }, + { + ...fireDeptWallet, + id: uuidv4(), + owner_type: 'DEPARTMENT', + owner_id: fireDeptId, + is_active: true, + }, + { + ...municipalityWallet, + id: uuidv4(), + owner_type: 'DEPARTMENT', + owner_id: municipalityId, + is_active: true, + }, ].map(w => ({ id: w.id, address: w.address, @@ -122,28 +256,39 @@ export async function seed(knex: Knex): Promise { await knex('wallets').insert(departmentWallets); - // Generate wallets for demo users + // ============================================ + // USERS (Admin + Department Users) + // ============================================ const adminWallet = generateWallet(); - const fireUserWallet = generateWallet(); + const transportUserWallet = generateWallet(); + const revenueUserWallet = generateWallet(); + const policeUserWallet = generateWallet(); const tourismUserWallet = generateWallet(); + const healthUserWallet = generateWallet(); + const fireUserWallet = generateWallet(); const municipalityUserWallet = generateWallet(); - const citizenWallet = generateWallet(); - const secondCitizenWallet = generateWallet(); - // Create demo users with specified credentials const adminUserId = uuidv4(); - const fireUserId = uuidv4(); + const transportUserId = uuidv4(); + const revenueUserId = uuidv4(); + const policeUserId = uuidv4(); const tourismUserId = uuidv4(); + const healthUserId = uuidv4(); + const fireUserId = uuidv4(); const municipalityUserId = uuidv4(); - const citizenUserId = uuidv4(); - const secondCitizenUserId = uuidv4(); + + // Generate citizen wallets + const citizenWallets: { id: string; wallet: ReturnType }[] = []; + for (let i = 0; i < 12; i++) { + citizenWallets.push({ id: uuidv4(), wallet: generateWallet() }); + } const users = [ { id: adminUserId, email: 'admin@goa.gov.in', password_hash: await bcrypt.hash('Admin@123', 10), - name: 'System Administrator', + name: 'Dr. Pramod Sawant', role: 'ADMIN', department_id: null, wallet_address: adminWallet.address, @@ -152,63 +297,136 @@ export async function seed(knex: Knex): Promise { is_active: true, }, { - id: fireUserId, - email: 'fire@goa.gov.in', - password_hash: await bcrypt.hash('Fire@123', 10), - name: 'Fire Department Officer', + id: transportUserId, + email: 'transport@goa.gov.in', + password_hash: await bcrypt.hash('Transport@123', 10), + name: 'Shri Ravi Naik', role: 'DEPARTMENT', - department_id: fireDeptId, - wallet_address: fireUserWallet.address, - wallet_encrypted_key: fireUserWallet.encryptedPrivateKey, + department_id: transportDeptId, + wallet_address: transportUserWallet.address, + wallet_encrypted_key: transportUserWallet.encryptedPrivateKey, phone: '+91-9876543211', is_active: true, }, + { + id: revenueUserId, + email: 'revenue@goa.gov.in', + password_hash: await bcrypt.hash('Revenue@123', 10), + name: 'Smt. Priya Tendulkar', + role: 'DEPARTMENT', + department_id: revenueDeptId, + wallet_address: revenueUserWallet.address, + wallet_encrypted_key: revenueUserWallet.encryptedPrivateKey, + phone: '+91-9876543212', + is_active: true, + }, + { + id: policeUserId, + email: 'police@goa.gov.in', + password_hash: await bcrypt.hash('Police@123', 10), + name: 'IPS Jaspal Singh', + role: 'DEPARTMENT', + department_id: policeDeptId, + wallet_address: policeUserWallet.address, + wallet_encrypted_key: policeUserWallet.encryptedPrivateKey, + phone: '+91-9876543213', + is_active: true, + }, { id: tourismUserId, email: 'tourism@goa.gov.in', password_hash: await bcrypt.hash('Tourism@123', 10), - name: 'Tourism Department Officer', + name: 'Shri Rohan Khaunte', role: 'DEPARTMENT', department_id: tourismDeptId, wallet_address: tourismUserWallet.address, wallet_encrypted_key: tourismUserWallet.encryptedPrivateKey, - phone: '+91-9876543212', + phone: '+91-9876543214', + is_active: true, + }, + { + id: healthUserId, + email: 'health@goa.gov.in', + password_hash: await bcrypt.hash('Health@123', 10), + name: 'Dr. Vishwajit Rane', + role: 'DEPARTMENT', + department_id: healthDeptId, + wallet_address: healthUserWallet.address, + wallet_encrypted_key: healthUserWallet.encryptedPrivateKey, + phone: '+91-9876543215', + is_active: true, + }, + { + id: fireUserId, + email: 'fire@goa.gov.in', + password_hash: await bcrypt.hash('Fire@123', 10), + name: 'Shri Ashok Menon', + role: 'DEPARTMENT', + department_id: fireDeptId, + wallet_address: fireUserWallet.address, + wallet_encrypted_key: fireUserWallet.encryptedPrivateKey, + phone: '+91-9876543216', is_active: true, }, { id: municipalityUserId, email: 'municipality@goa.gov.in', password_hash: await bcrypt.hash('Municipality@123', 10), - name: 'Municipality Officer', + name: 'Smt. Babita Shirodkar', role: 'DEPARTMENT', department_id: municipalityId, wallet_address: municipalityUserWallet.address, wallet_encrypted_key: municipalityUserWallet.encryptedPrivateKey, - phone: '+91-9876543213', + phone: '+91-9876543217', + is_active: true, + }, + // Citizen users + { + id: citizenWallets[0].id, + email: 'rajesh.naik@example.com', + password_hash: await bcrypt.hash('Citizen@123', 10), + name: 'Rajesh Vishnu Naik', + role: 'CITIZEN', + department_id: null, + wallet_address: citizenWallets[0].wallet.address, + wallet_encrypted_key: citizenWallets[0].wallet.encryptedPrivateKey, + phone: '+91-9823456789', is_active: true, }, { - id: citizenUserId, - email: 'citizen@example.com', + id: citizenWallets[1].id, + email: 'priya.kamat@example.com', password_hash: await bcrypt.hash('Citizen@123', 10), - name: 'Demo Citizen', + name: 'Priya Shankar Kamat', role: 'CITIZEN', department_id: null, - wallet_address: citizenWallet.address, - wallet_encrypted_key: citizenWallet.encryptedPrivateKey, - phone: '+91-9876543214', + wallet_address: citizenWallets[1].wallet.address, + wallet_encrypted_key: citizenWallets[1].wallet.encryptedPrivateKey, + phone: '+91-9823456790', is_active: true, }, { - id: secondCitizenUserId, - email: 'citizen2@example.com', + id: citizenWallets[2].id, + email: 'antonio.fernandes@example.com', password_hash: await bcrypt.hash('Citizen@123', 10), - name: 'Second Citizen', + name: 'Antonio Jose Fernandes', role: 'CITIZEN', department_id: null, - wallet_address: secondCitizenWallet.address, - wallet_encrypted_key: secondCitizenWallet.encryptedPrivateKey, - phone: '+91-9876543215', + wallet_address: citizenWallets[2].wallet.address, + wallet_encrypted_key: citizenWallets[2].wallet.encryptedPrivateKey, + phone: '+91-9823456791', + is_active: true, + }, + { + id: citizenWallets[3].id, + email: 'sunita.desai@example.com', + password_hash: await bcrypt.hash('Citizen@123', 10), + name: 'Sunita Ramesh Desai', + role: 'CITIZEN', + department_id: null, + wallet_address: citizenWallets[3].wallet.address, + wallet_encrypted_key: citizenWallets[3].wallet.encryptedPrivateKey, + phone: '+91-9823456792', is_active: true, }, ]; @@ -218,11 +436,56 @@ export async function seed(knex: Knex): Promise { // Store user wallets const userWallets = [ { ...adminWallet, id: uuidv4(), owner_type: 'USER', owner_id: adminUserId, is_active: true }, + { + ...transportUserWallet, + id: uuidv4(), + owner_type: 'USER', + owner_id: transportUserId, + is_active: true, + }, + { + ...revenueUserWallet, + id: uuidv4(), + owner_type: 'USER', + owner_id: revenueUserId, + is_active: true, + }, + { + ...policeUserWallet, + id: uuidv4(), + owner_type: 'USER', + owner_id: policeUserId, + is_active: true, + }, + { + ...tourismUserWallet, + id: uuidv4(), + owner_type: 'USER', + owner_id: tourismUserId, + is_active: true, + }, + { + ...healthUserWallet, + id: uuidv4(), + owner_type: 'USER', + owner_id: healthUserId, + is_active: true, + }, { ...fireUserWallet, id: uuidv4(), owner_type: 'USER', owner_id: fireUserId, is_active: true }, - { ...tourismUserWallet, id: uuidv4(), owner_type: 'USER', owner_id: tourismUserId, is_active: true }, - { ...municipalityUserWallet, id: uuidv4(), owner_type: 'USER', owner_id: municipalityUserId, is_active: true }, - { ...citizenWallet, id: uuidv4(), owner_type: 'USER', owner_id: citizenUserId, is_active: true }, - { ...secondCitizenWallet, id: uuidv4(), owner_type: 'USER', owner_id: secondCitizenUserId, is_active: true }, + { + ...municipalityUserWallet, + id: uuidv4(), + owner_type: 'USER', + owner_id: municipalityUserId, + is_active: true, + }, + ...citizenWallets.slice(0, 4).map(c => ({ + ...c.wallet, + id: uuidv4(), + owner_type: 'USER', + owner_id: c.id, + is_active: true, + })), ].map(w => ({ id: w.id, address: w.address, @@ -234,169 +497,1224 @@ export async function seed(knex: Knex): Promise { await knex('wallets').insert(userWallets); - // Create sample workflow for Resort License - const workflowId = uuidv4(); - await knex('workflows').insert({ - id: workflowId, - workflow_type: 'RESORT_LICENSE', - name: 'Resort License Approval Workflow', - description: 'Multi-department approval workflow for resort licenses in Goa', - version: 1, - definition: JSON.stringify({ - isActive: true, - stages: [ - { - stageId: 'stage_1_fire', - stageName: 'Fire Safety Review', - stageOrder: 1, - executionType: 'SEQUENTIAL', - requiredApprovals: [ - { - departmentCode: 'FIRE_DEPT', - departmentName: 'Fire & Emergency Services Department', - requiredDocuments: ['FIRE_SAFETY_CERTIFICATE', 'BUILDING_PLAN'], - isMandatory: true, - }, - ], - completionCriteria: 'ALL', - timeoutDays: 7, - onTimeout: 'NOTIFY', - onRejection: 'FAIL_REQUEST', - }, - { - stageId: 'stage_2_parallel', - stageName: 'Tourism & Municipality Review', - stageOrder: 2, - executionType: 'PARALLEL', - requiredApprovals: [ - { - departmentCode: 'TOURISM_DEPT', - departmentName: 'Department of Tourism', - requiredDocuments: ['PROPERTY_OWNERSHIP', 'BUILDING_PLAN'], - isMandatory: true, - }, - { - departmentCode: 'MUNICIPALITY', - departmentName: 'Municipal Corporation of Panaji', - requiredDocuments: ['PROPERTY_OWNERSHIP', 'TAX_CLEARANCE'], - isMandatory: true, - }, - ], - completionCriteria: 'ALL', - timeoutDays: 14, - onTimeout: 'ESCALATE', - onRejection: 'FAIL_REQUEST', - }, - { - stageId: 'stage_3_health', - stageName: 'Health & Sanitation Review', - stageOrder: 3, - executionType: 'SEQUENTIAL', - requiredApprovals: [ - { - departmentCode: 'HEALTH_DEPT', - departmentName: 'Directorate of Health Services', - requiredDocuments: ['HEALTH_CERTIFICATE'], - isMandatory: true, - }, - ], - completionCriteria: 'ALL', - timeoutDays: 7, - onTimeout: 'NOTIFY', - onRejection: 'FAIL_REQUEST', - }, - ], - }), - is_active: true, - }); + // ============================================ + // APPLICANTS (12 realistic Goan names) + // ============================================ + const applicants = [ + { + id: citizenWallets[0].id, + digilocker_id: 'DL-GOA-2024-001', + name: 'Rajesh Vishnu Naik', + email: 'rajesh.naik@example.com', + phone: '+91-9823456789', + wallet_address: citizenWallets[0].wallet.address, + is_active: true, + }, + { + id: citizenWallets[1].id, + digilocker_id: 'DL-GOA-2024-002', + name: 'Priya Shankar Kamat', + email: 'priya.kamat@example.com', + phone: '+91-9823456790', + wallet_address: citizenWallets[1].wallet.address, + is_active: true, + }, + { + id: citizenWallets[2].id, + digilocker_id: 'DL-GOA-2024-003', + name: 'Antonio Jose Fernandes', + email: 'antonio.fernandes@example.com', + phone: '+91-9823456791', + wallet_address: citizenWallets[2].wallet.address, + is_active: true, + }, + { + id: citizenWallets[3].id, + digilocker_id: 'DL-GOA-2024-004', + name: 'Sunita Ramesh Desai', + email: 'sunita.desai@example.com', + phone: '+91-9823456792', + wallet_address: citizenWallets[3].wallet.address, + is_active: true, + }, + { + id: citizenWallets[4].id, + digilocker_id: 'DL-GOA-2024-005', + name: "Francisco Xavier D'Souza", + email: 'francisco.dsouza@example.com', + phone: '+91-9823456793', + wallet_address: citizenWallets[4].wallet.address, + is_active: true, + }, + { + id: citizenWallets[5].id, + digilocker_id: 'DL-GOA-2024-006', + name: 'Mangesh Pramod Shirodkar', + email: 'mangesh.shirodkar@example.com', + phone: '+91-9823456794', + wallet_address: citizenWallets[5].wallet.address, + is_active: true, + }, + { + id: citizenWallets[6].id, + digilocker_id: 'DL-GOA-2024-007', + name: 'Maria Conceicao Pereira', + email: 'maria.pereira@example.com', + phone: '+91-9823456795', + wallet_address: citizenWallets[6].wallet.address, + is_active: true, + }, + { + id: citizenWallets[7].id, + digilocker_id: 'DL-GOA-2024-008', + name: 'Vitthal Ganesh Prabhu', + email: 'vitthal.prabhu@example.com', + phone: '+91-9823456796', + wallet_address: citizenWallets[7].wallet.address, + is_active: true, + }, + { + id: citizenWallets[8].id, + digilocker_id: 'DL-GOA-2024-009', + name: 'Anjali Mohan Naik', + email: 'anjali.naik@example.com', + phone: '+91-9823456797', + wallet_address: citizenWallets[8].wallet.address, + is_active: true, + }, + { + id: citizenWallets[9].id, + digilocker_id: 'DL-GOA-2024-010', + name: 'Peter Anthony Rodrigues', + email: 'peter.rodrigues@example.com', + phone: '+91-9823456798', + wallet_address: citizenWallets[9].wallet.address, + is_active: true, + }, + { + id: citizenWallets[10].id, + digilocker_id: 'DL-GOA-2024-011', + name: 'Deepa Narayan Phadte', + email: 'deepa.phadte@example.com', + phone: '+91-9823456799', + wallet_address: citizenWallets[10].wallet.address, + is_active: true, + }, + { + id: citizenWallets[11].id, + digilocker_id: 'DL-GOA-2024-012', + name: 'Anand Krishna Shetgaonkar', + email: 'anand.shetgaonkar@example.com', + phone: '+91-9823456800', + wallet_address: citizenWallets[11].wallet.address, + is_active: true, + }, + ]; - // Create FIRE_SAFETY_CERT workflow for tests + await knex('applicants').insert(applicants); + + // ============================================ + // WORKFLOWS (5 different workflow types) + // ============================================ + const resortLicenseWorkflowId = uuidv4(); + const tradeLicenseWorkflowId = uuidv4(); + const buildingPermitWorkflowId = uuidv4(); const fireSafetyCertWorkflowId = uuidv4(); - await knex('workflows').insert({ - id: fireSafetyCertWorkflowId, - workflow_type: 'FIRE_SAFETY_CERT', - name: 'Fire Safety Certificate Workflow', - description: 'Simplified fire safety certificate approval workflow', - version: 1, - definition: JSON.stringify({ - isActive: true, - stages: [ + const healthCertWorkflowId = uuidv4(); + + const workflows = [ + { + id: resortLicenseWorkflowId, + workflow_type: 'RESORT_LICENSE', + name: 'Resort/Hotel License Approval Workflow', + description: + 'Multi-department approval workflow for resort and hotel licenses in Goa. Requires fire safety, tourism, municipality, and health clearances.', + version: 1, + definition: JSON.stringify({ + isActive: true, + stages: [ + { + stageId: 'stage_1_fire', + stageName: 'Fire Safety Inspection', + stageOrder: 1, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'FIRE_DEPT', + departmentName: 'Goa Fire & Emergency Services', + requiredDocuments: ['FIRE_SAFETY_CERTIFICATE', 'BUILDING_PLAN'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 7, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + { + stageId: 'stage_2_parallel', + stageName: 'Tourism & Municipality Review', + stageOrder: 2, + executionType: 'PARALLEL', + requiredApprovals: [ + { + departmentCode: 'TOURISM_DEPT', + departmentName: 'Goa Tourism Development Corporation', + requiredDocuments: ['PROPERTY_OWNERSHIP', 'BUILDING_PLAN'], + isMandatory: true, + }, + { + departmentCode: 'MUNICIPALITY', + departmentName: 'Corporation of the City of Panaji', + requiredDocuments: ['PROPERTY_OWNERSHIP', 'TAX_CLEARANCE'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 14, + onTimeout: 'ESCALATE', + onRejection: 'FAIL_REQUEST', + }, + { + stageId: 'stage_3_health', + stageName: 'Health & Sanitation Clearance', + stageOrder: 3, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'HEALTH_DEPT', + departmentName: 'Directorate of Health Services, Goa', + requiredDocuments: ['HEALTH_CERTIFICATE'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 7, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + ], + }), + is_active: true, + }, + { + id: tradeLicenseWorkflowId, + workflow_type: 'TRADE_LICENSE', + name: 'Trade License Approval Workflow', + description: 'Workflow for issuing trade licenses to commercial establishments in Goa', + version: 1, + definition: JSON.stringify({ + isActive: true, + stages: [ + { + stageId: 'stage_1_municipality', + stageName: 'Municipal Verification', + stageOrder: 1, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'MUNICIPALITY', + departmentName: 'Corporation of the City of Panaji', + requiredDocuments: ['PROPERTY_OWNERSHIP', 'TAX_CLEARANCE', 'IDENTITY_PROOF'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 10, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + { + stageId: 'stage_2_fire', + stageName: 'Fire Safety Verification', + stageOrder: 2, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'FIRE_DEPT', + departmentName: 'Goa Fire & Emergency Services', + requiredDocuments: ['FIRE_SAFETY_CERTIFICATE'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 7, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + ], + }), + is_active: true, + }, + { + id: buildingPermitWorkflowId, + workflow_type: 'BUILDING_PERMIT', + name: 'Building Permit Approval Workflow', + description: 'Multi-stage workflow for construction and building permits in Goa', + version: 1, + definition: JSON.stringify({ + isActive: true, + stages: [ + { + stageId: 'stage_1_revenue', + stageName: 'Land Verification', + stageOrder: 1, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'REVENUE_DEPT', + departmentName: 'Directorate of Land Revenue, Goa', + requiredDocuments: ['PROPERTY_OWNERSHIP', 'IDENTITY_PROOF'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 10, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + { + stageId: 'stage_2_municipality', + stageName: 'Building Plan Approval', + stageOrder: 2, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'MUNICIPALITY', + departmentName: 'Corporation of the City of Panaji', + requiredDocuments: ['BUILDING_PLAN', 'ENVIRONMENTAL_CLEARANCE'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 14, + onTimeout: 'ESCALATE', + onRejection: 'FAIL_REQUEST', + }, + { + stageId: 'stage_3_fire', + stageName: 'Fire Safety Plan Review', + stageOrder: 3, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'FIRE_DEPT', + departmentName: 'Goa Fire & Emergency Services', + requiredDocuments: ['FIRE_SAFETY_CERTIFICATE', 'BUILDING_PLAN'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 7, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + ], + }), + is_active: true, + }, + { + id: fireSafetyCertWorkflowId, + workflow_type: 'FIRE_SAFETY_CERT', + name: 'Fire Safety Certificate Workflow', + description: 'Simplified fire safety certificate approval workflow', + version: 1, + definition: JSON.stringify({ + isActive: true, + stages: [ + { + stageId: 'stage_1_fire', + stageName: 'Fire Safety Review', + stageOrder: 1, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'FIRE_DEPT', + departmentName: 'Goa Fire & Emergency Services', + requiredDocuments: ['FIRE_SAFETY_CERTIFICATE', 'BUILDING_PLAN'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 7, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + ], + }), + is_active: true, + }, + { + id: healthCertWorkflowId, + workflow_type: 'HEALTH_CERT', + name: 'Health & Sanitation Certificate Workflow', + description: 'Workflow for health and sanitation certificates for food establishments', + version: 1, + definition: JSON.stringify({ + isActive: true, + stages: [ + { + stageId: 'stage_1_health', + stageName: 'Health Inspection', + stageOrder: 1, + executionType: 'SEQUENTIAL', + requiredApprovals: [ + { + departmentCode: 'HEALTH_DEPT', + departmentName: 'Directorate of Health Services, Goa', + requiredDocuments: ['HEALTH_CERTIFICATE', 'IDENTITY_PROOF'], + isMandatory: true, + }, + ], + completionCriteria: 'ALL', + timeoutDays: 7, + onTimeout: 'NOTIFY', + onRejection: 'FAIL_REQUEST', + }, + ], + }), + is_active: true, + }, + ]; + + await knex('workflows').insert(workflows); + + // ============================================ + // LICENSE REQUESTS (25 requests in various statuses) + // ============================================ + const licenseRequests: any[] = []; + const documents: any[] = []; + const documentVersions: any[] = []; + const approvals: any[] = []; + const workflowStates: any[] = []; + const auditLogs: any[] = []; + const blockchainTransactions: any[] = []; + + // Helper function to create a complete license request with documents and approvals + const createLicenseRequest = ( + applicantId: string, + applicantName: string, + requestType: string, + workflowId: string, + status: string, + metadata: any, + requestNumber: string, + createdDate: Date, + departments: { id: string; status: string; remarks?: string }[], + currentStageId: string, + ) => { + const requestId = uuidv4(); + const submittedAt = status !== 'DRAFT' ? sequentialDate(createdDate, 1) : null; + const approvedAt = status === 'APPROVED' ? sequentialDate(createdDate, 72) : null; + const tokenId = status === 'APPROVED' ? Math.floor(Math.random() * 10000) + 1 : null; + + // Create the license request + licenseRequests.push({ + id: requestId, + request_number: requestNumber, + token_id: tokenId, + applicant_id: applicantId, + request_type: requestType, + workflow_id: workflowId, + status, + metadata: JSON.stringify(metadata), + current_stage_id: currentStageId, + blockchain_tx_hash: status === 'APPROVED' ? fakeTxHash() : null, + created_at: createdDate, + updated_at: status !== 'DRAFT' ? submittedAt : createdDate, + submitted_at: submittedAt, + approved_at: approvedAt, + }); + + // Create documents for each request + const docTypes = [ + 'PROPERTY_OWNERSHIP', + 'FIRE_SAFETY_CERTIFICATE', + 'BUILDING_PLAN', + 'TAX_CLEARANCE', + ]; + docTypes.forEach((docType, index) => { + const docId = uuidv4(); + const docHash = fakeDocHash(); + documents.push({ + id: docId, + request_id: requestId, + doc_type: docType, + original_filename: `${docType.toLowerCase().replace(/_/g, '-')}-${requestNumber}.pdf`, + current_version: 1, + current_hash: docHash, + minio_bucket: 'goa-gel-documents', + is_active: true, + created_at: createdDate, + updated_at: createdDate, + }); + + // Create document version + const versionId = uuidv4(); + documentVersions.push({ + id: versionId, + document_id: docId, + version: 1, + hash: docHash, + minio_path: `documents/${requestId}/${docType.toLowerCase()}/${versionId}.pdf`, + file_size: Math.floor(Math.random() * 5000000) + 100000, + mime_type: 'application/pdf', + uploaded_by: applicantId, + blockchain_tx_hash: null, + created_at: createdDate, + }); + }); + + // Create approvals for each department + departments.forEach((dept, index) => { + const approvalId = uuidv4(); + const approvalDate = sequentialDate(createdDate, (index + 1) * 24); + approvals.push({ + id: approvalId, + request_id: requestId, + department_id: dept.id, + status: dept.status, + remarks: dept.remarks || null, + remarks_hash: dept.remarks ? fakeDocHash() : null, + reviewed_documents: JSON.stringify(['PROPERTY_OWNERSHIP', 'FIRE_SAFETY_CERTIFICATE']), + blockchain_tx_hash: dept.status === 'APPROVED' ? fakeTxHash() : null, + is_active: true, + created_at: approvalDate, + updated_at: approvalDate, + }); + }); + + // Create workflow state + const completedStages = + status === 'APPROVED' + ? ['stage_1_fire', 'stage_2_parallel', 'stage_3_health'] + : status === 'IN_REVIEW' && currentStageId === 'stage_2_parallel' + ? ['stage_1_fire'] + : []; + + workflowStates.push({ + id: uuidv4(), + request_id: requestId, + current_stage_id: currentStageId, + completed_stages: JSON.stringify(completedStages), + pending_approvals: JSON.stringify( + departments.filter(d => d.status === 'PENDING').map(d => d.id), + ), + execution_log: JSON.stringify([ { + timestamp: createdDate.toISOString(), + action: 'WORKFLOW_STARTED', stageId: 'stage_1_fire', - stageName: 'Fire Safety Review', - stageOrder: 1, - executionType: 'SEQUENTIAL', - requiredApprovals: [ - { - departmentCode: 'FIRE_DEPT', - departmentName: 'Fire & Emergency Services Department', - requiredDocuments: ['FIRE_SAFETY_CERTIFICATE', 'BUILDING_PLAN'], - isMandatory: true, - }, - ], - completionCriteria: 'ALL', - timeoutDays: 7, - onTimeout: 'NOTIFY', - onRejection: 'FAIL_REQUEST', }, - { - stageId: 'stage_2_health', - stageName: 'Health & Safety Review', - stageOrder: 2, - executionType: 'SEQUENTIAL', - requiredApprovals: [ - { - departmentCode: 'HEALTH_DEPT', - departmentName: 'Directorate of Health Services', - requiredDocuments: ['HEALTH_CERTIFICATE'], - isMandatory: true, - }, - ], - completionCriteria: 'ALL', - timeoutDays: 7, - onTimeout: 'NOTIFY', - onRejection: 'FAIL_REQUEST', - }, - ], - }), - is_active: true, - }); + ]), + stage_started_at: createdDate, + created_at: createdDate, + updated_at: status !== 'DRAFT' ? submittedAt : createdDate, + }); - // Create sample applicants (linked to citizen users) - await knex('applicants').insert([ + // Create audit log entries + auditLogs.push({ + id: uuidv4(), + entity_type: 'REQUEST', + entity_id: requestId, + action: 'CREATE', + actor_type: 'APPLICANT', + actor_id: applicantId, + old_value: null, + new_value: JSON.stringify({ status: 'DRAFT', requestNumber }), + ip_address: '192.168.1.' + Math.floor(Math.random() * 254 + 1), + user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + correlation_id: uuidv4(), + created_at: createdDate, + }); + + if (status !== 'DRAFT') { + auditLogs.push({ + id: uuidv4(), + entity_type: 'REQUEST', + entity_id: requestId, + action: 'SUBMIT', + actor_type: 'APPLICANT', + actor_id: applicantId, + old_value: JSON.stringify({ status: 'DRAFT' }), + new_value: JSON.stringify({ status: 'SUBMITTED' }), + ip_address: '192.168.1.' + Math.floor(Math.random() * 254 + 1), + user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + correlation_id: uuidv4(), + created_at: submittedAt, + }); + } + + if (status === 'APPROVED') { + auditLogs.push({ + id: uuidv4(), + entity_type: 'REQUEST', + entity_id: requestId, + action: 'APPROVE', + actor_type: 'SYSTEM', + actor_id: null, + old_value: JSON.stringify({ status: 'IN_REVIEW' }), + new_value: JSON.stringify({ status: 'APPROVED', tokenId }), + ip_address: null, + user_agent: 'GEL-Backend-System', + correlation_id: uuidv4(), + created_at: approvedAt, + }); + + // Add blockchain transaction for approved requests + blockchainTransactions.push({ + id: uuidv4(), + tx_hash: fakeTxHash(), + tx_type: 'MINT_NFT', + related_entity_type: 'REQUEST', + related_entity_id: requestId, + from_address: citizenWallets[0].wallet.address, + to_address: '0x' + crypto.randomBytes(20).toString('hex'), + status: 'CONFIRMED', + block_number: Math.floor(Math.random() * 1000000) + 1000000, + gas_used: Math.floor(Math.random() * 500000) + 100000, + error_message: null, + created_at: approvedAt, + confirmed_at: sequentialDate(approvedAt!, 0.1), + }); + } + + if (status === 'REJECTED') { + auditLogs.push({ + id: uuidv4(), + entity_type: 'REQUEST', + entity_id: requestId, + action: 'REJECT', + actor_type: 'DEPARTMENT', + actor_id: departments[0].id, + old_value: JSON.stringify({ status: 'IN_REVIEW' }), + new_value: JSON.stringify({ + status: 'REJECTED', + reason: 'Documents incomplete or invalid', + }), + ip_address: '10.0.0.' + Math.floor(Math.random() * 254 + 1), + user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + correlation_id: uuidv4(), + created_at: sequentialDate(createdDate, 48), + }); + } + + return requestId; + }; + + // Create 25 license requests with various statuses + // APPROVED (5) + createLicenseRequest( + applicants[0].id, + applicants[0].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'APPROVED', { - id: citizenUserId, // Use same ID for linking - digilocker_id: 'DL-GOA-CITIZEN-001', - name: 'Demo Citizen', - email: 'citizen@example.com', - phone: '+91-9876543214', - wallet_address: citizenWallet.address, + propertyName: 'Goa Beach Resort', + location: 'Calangute Beach Road, Bardez', + area: '5000 sqm', + rooms: 50, + }, + 'GOA-RL-2024-0001', + randomDate(28), + [ + { id: fireDeptId, status: 'APPROVED', remarks: 'Fire safety measures are adequate' }, + { id: tourismDeptId, status: 'APPROVED', remarks: 'Tourism standards met' }, + { id: municipalityId, status: 'APPROVED', remarks: 'Building regulations compliant' }, + { + id: healthDeptId, + status: 'APPROVED', + remarks: 'Health and sanitation requirements satisfied', + }, + ], + 'stage_3_health', + ); + + createLicenseRequest( + applicants[1].id, + applicants[1].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'APPROVED', + { + businessName: 'Kamat Provisions Store', + location: '18th June Road, Panaji', + businessType: 'Retail - Groceries', + }, + 'GOA-TL-2024-0001', + randomDate(25), + [ + { id: municipalityId, status: 'APPROVED', remarks: 'All documents verified' }, + { id: fireDeptId, status: 'APPROVED', remarks: 'Fire extinguishers installed' }, + ], + 'stage_2_fire', + ); + + createLicenseRequest( + applicants[2].id, + applicants[2].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'APPROVED', + { + propertyName: 'Fernandes Villa Resort', + location: 'Dona Paula Beach Road', + area: '3500 sqm', + rooms: 30, + }, + 'GOA-RL-2024-0002', + randomDate(22), + [ + { id: fireDeptId, status: 'APPROVED', remarks: 'Excellent fire safety compliance' }, + { id: tourismDeptId, status: 'APPROVED', remarks: 'Luxury category approved' }, + { id: municipalityId, status: 'APPROVED', remarks: 'Property tax cleared' }, + { id: healthDeptId, status: 'APPROVED', remarks: 'Kitchen and sanitation inspected' }, + ], + 'stage_3_health', + ); + + createLicenseRequest( + applicants[3].id, + applicants[3].name, + 'BUILDING_PERMIT', + buildingPermitWorkflowId, + 'APPROVED', + { projectName: 'Desai Residency', location: 'Mapusa', floors: 4, builtUpArea: '1200 sqm' }, + 'GOA-BP-2024-0001', + randomDate(20), + [ + { id: revenueDeptId, status: 'APPROVED', remarks: 'Land ownership verified' }, + { id: municipalityId, status: 'APPROVED', remarks: 'Building plan approved as per norms' }, + { id: fireDeptId, status: 'APPROVED', remarks: 'Fire staircase and exit plan approved' }, + ], + 'stage_3_fire', + ); + + createLicenseRequest( + applicants[4].id, + applicants[4].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'APPROVED', + { + businessName: "D'Souza Electronics", + location: 'Margao Market', + businessType: 'Retail - Electronics', + }, + 'GOA-TL-2024-0002', + randomDate(18), + [ + { id: municipalityId, status: 'APPROVED', remarks: 'Shop registration complete' }, + { id: fireDeptId, status: 'APPROVED', remarks: 'Electrical safety verified' }, + ], + 'stage_2_fire', + ); + + // IN_REVIEW (7) + createLicenseRequest( + applicants[5].id, + applicants[5].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'IN_REVIEW', + { + propertyName: 'Shirodkar Beach Shacks', + location: 'Anjuna Beach', + area: '2000 sqm', + rooms: 20, + }, + 'GOA-RL-2024-0003', + randomDate(15), + [ + { id: fireDeptId, status: 'APPROVED', remarks: 'Fire safety adequate for beach shack setup' }, + { id: tourismDeptId, status: 'PENDING' }, + { id: municipalityId, status: 'PENDING' }, + ], + 'stage_2_parallel', + ); + + createLicenseRequest( + applicants[6].id, + applicants[6].name, + 'BUILDING_PERMIT', + buildingPermitWorkflowId, + 'IN_REVIEW', + { + projectName: 'Pereira Commercial Complex', + location: 'Vasco da Gama', + floors: 6, + builtUpArea: '3000 sqm', + }, + 'GOA-BP-2024-0002', + randomDate(12), + [ + { id: revenueDeptId, status: 'APPROVED', remarks: 'Land records clear' }, + { id: municipalityId, status: 'PENDING' }, + ], + 'stage_2_municipality', + ); + + createLicenseRequest( + applicants[7].id, + applicants[7].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'IN_REVIEW', + { propertyName: 'Prabhu Heritage Resort', location: 'Old Goa', area: '4000 sqm', rooms: 35 }, + 'GOA-RL-2024-0004', + randomDate(10), + [{ id: fireDeptId, status: 'PENDING' }], + 'stage_1_fire', + ); + + createLicenseRequest( + applicants[8].id, + applicants[8].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'IN_REVIEW', + { + businessName: 'Naik Restaurant', + location: 'Panjim Bus Stand Area', + businessType: 'Food & Beverage', + }, + 'GOA-TL-2024-0003', + randomDate(8), + [ + { id: municipalityId, status: 'APPROVED', remarks: 'Location approved for restaurant' }, + { id: fireDeptId, status: 'PENDING' }, + ], + 'stage_2_fire', + ); + + createLicenseRequest( + applicants[9].id, + applicants[9].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'IN_REVIEW', + { propertyName: 'Rodrigues Beach Resort', location: 'Baga Beach', area: '6000 sqm', rooms: 60 }, + 'GOA-RL-2024-0005', + randomDate(7), + [ + { id: fireDeptId, status: 'APPROVED', remarks: 'Fire hydrant system installed' }, + { id: tourismDeptId, status: 'APPROVED', remarks: '5-star category recommended' }, + { id: municipalityId, status: 'APPROVED', remarks: 'All clearances in order' }, + { id: healthDeptId, status: 'PENDING' }, + ], + 'stage_3_health', + ); + + createLicenseRequest( + applicants[10].id, + applicants[10].name, + 'BUILDING_PERMIT', + buildingPermitWorkflowId, + 'IN_REVIEW', + { projectName: 'Phadte Apartments', location: 'Porvorim', floors: 5, builtUpArea: '2500 sqm' }, + 'GOA-BP-2024-0003', + randomDate(6), + [{ id: revenueDeptId, status: 'PENDING' }], + 'stage_1_revenue', + ); + + createLicenseRequest( + applicants[11].id, + applicants[11].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'IN_REVIEW', + { + businessName: 'Shetgaonkar Auto Parts', + location: 'Bicholim', + businessType: 'Automotive Parts', + }, + 'GOA-TL-2024-0004', + randomDate(5), + [{ id: municipalityId, status: 'PENDING' }], + 'stage_1_municipality', + ); + + // SUBMITTED (5) + createLicenseRequest( + applicants[0].id, + applicants[0].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'SUBMITTED', + { + businessName: 'Naik Pharmacy', + location: 'Mapusa Market', + businessType: 'Healthcare - Pharmacy', + }, + 'GOA-TL-2024-0005', + randomDate(4), + [{ id: municipalityId, status: 'PENDING' }], + 'stage_1_municipality', + ); + + createLicenseRequest( + applicants[1].id, + applicants[1].name, + 'BUILDING_PERMIT', + buildingPermitWorkflowId, + 'SUBMITTED', + { + projectName: 'Kamat Industrial Shed', + location: 'Ponda Industrial Estate', + floors: 1, + builtUpArea: '500 sqm', + }, + 'GOA-BP-2024-0004', + randomDate(3), + [{ id: revenueDeptId, status: 'PENDING' }], + 'stage_1_revenue', + ); + + createLicenseRequest( + applicants[2].id, + applicants[2].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'SUBMITTED', + { + propertyName: 'Fernandes Boutique Hotel', + location: 'Fontainhas, Panaji', + area: '1500 sqm', + rooms: 15, + }, + 'GOA-RL-2024-0006', + randomDate(2), + [{ id: fireDeptId, status: 'PENDING' }], + 'stage_1_fire', + ); + + createLicenseRequest( + applicants[3].id, + applicants[3].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'SUBMITTED', + { businessName: 'Desai Textiles', location: 'Margao', businessType: 'Retail - Clothing' }, + 'GOA-TL-2024-0006', + randomDate(2), + [{ id: municipalityId, status: 'PENDING' }], + 'stage_1_municipality', + ); + + createLicenseRequest( + applicants[4].id, + applicants[4].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'SUBMITTED', + { propertyName: "D'Souza Eco Resort", location: 'Palolem Beach', area: '4500 sqm', rooms: 40 }, + 'GOA-RL-2024-0007', + randomDate(1), + [{ id: fireDeptId, status: 'PENDING' }], + 'stage_1_fire', + ); + + // REJECTED (3) + createLicenseRequest( + applicants[5].id, + applicants[5].name, + 'BUILDING_PERMIT', + buildingPermitWorkflowId, + 'REJECTED', + { + projectName: 'Shirodkar Beach Construction', + location: 'CRZ Zone, Calangute', + floors: 3, + builtUpArea: '800 sqm', + }, + 'GOA-BP-2024-0005', + randomDate(20), + [ + { + id: revenueDeptId, + status: 'REJECTED', + remarks: 'Property falls under CRZ-I zone. Construction not permitted.', + }, + ], + 'stage_1_revenue', + ); + + createLicenseRequest( + applicants[6].id, + applicants[6].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'REJECTED', + { + propertyName: 'Pereira Guest House', + location: 'Candolim Beach Road', + area: '800 sqm', + rooms: 10, + }, + 'GOA-RL-2024-0008', + randomDate(18), + [ + { + id: fireDeptId, + status: 'REJECTED', + remarks: 'Fire exit width insufficient. Minimum 1.2m required.', + }, + ], + 'stage_1_fire', + ); + + createLicenseRequest( + applicants[7].id, + applicants[7].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'REJECTED', + { + businessName: 'Prabhu Fireworks Shop', + location: 'Residential Area, Margao', + businessType: 'Retail - Fireworks', + }, + 'GOA-TL-2024-0007', + randomDate(15), + [ + { + id: municipalityId, + status: 'REJECTED', + remarks: 'Location not approved for hazardous materials storage.', + }, + ], + 'stage_1_municipality', + ); + + // DRAFT (3) + createLicenseRequest( + applicants[8].id, + applicants[8].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'DRAFT', + { propertyName: 'Naik Heritage Villa', location: 'Siolim', area: '2500 sqm', rooms: 20 }, + 'GOA-RL-2024-0009', + randomDate(1), + [], + 'stage_1_fire', + ); + + createLicenseRequest( + applicants[9].id, + applicants[9].name, + 'BUILDING_PERMIT', + buildingPermitWorkflowId, + 'DRAFT', + { + projectName: 'Rodrigues Shopping Mall', + location: 'Porvorim', + floors: 4, + builtUpArea: '5000 sqm', + }, + 'GOA-BP-2024-0006', + randomDate(1), + [], + 'stage_1_revenue', + ); + + createLicenseRequest( + applicants[10].id, + applicants[10].name, + 'TRADE_LICENSE', + tradeLicenseWorkflowId, + 'DRAFT', + { businessName: 'Phadte Bakery', location: 'Ponda', businessType: 'Food & Beverage - Bakery' }, + 'GOA-TL-2024-0008', + randomDate(0), + [], + 'stage_1_municipality', + ); + + // PENDING_RESUBMISSION (2) + createLicenseRequest( + applicants[11].id, + applicants[11].name, + 'RESORT_LICENSE', + resortLicenseWorkflowId, + 'PENDING_RESUBMISSION', + { + propertyName: 'Shetgaonkar Riverside Resort', + location: 'Mandovi River Bank', + area: '3000 sqm', + rooms: 25, + }, + 'GOA-RL-2024-0010', + randomDate(12), + [ + { + id: fireDeptId, + status: 'CHANGES_REQUESTED', + remarks: 'Please submit updated fire escape plan with proper dimensions marked.', + }, + ], + 'stage_1_fire', + ); + + createLicenseRequest( + applicants[0].id, + applicants[0].name, + 'BUILDING_PERMIT', + buildingPermitWorkflowId, + 'PENDING_RESUBMISSION', + { projectName: 'Naik Commercial Plaza', location: 'Vasco', floors: 5, builtUpArea: '2000 sqm' }, + 'GOA-BP-2024-0007', + randomDate(10), + [ + { id: revenueDeptId, status: 'APPROVED', remarks: 'Land ownership confirmed' }, + { + id: municipalityId, + status: 'CHANGES_REQUESTED', + remarks: 'Building plan needs revision for setback requirements. Please resubmit.', + }, + ], + 'stage_2_municipality', + ); + + // Insert all license requests + await knex('license_requests').insert(licenseRequests); + + // Insert all documents + await knex('documents').insert(documents); + + // Insert all document versions + await knex('document_versions').insert(documentVersions); + + // Insert all approvals (filter out empty arrays) + if (approvals.length > 0) { + await knex('approvals').insert(approvals); + } + + // Insert all workflow states + await knex('workflow_states').insert(workflowStates); + + // Insert all audit logs + await knex('audit_logs').insert(auditLogs); + + // Insert blockchain transactions + if (blockchainTransactions.length > 0) { + await knex('blockchain_transactions').insert(blockchainTransactions); + } + + // ============================================ + // WEBHOOKS (for each department) + // ============================================ + const webhooks = [ + { + id: uuidv4(), + department_id: fireDeptId, + url: 'https://fire.goa.gov.in/api/webhooks/gel', + events: JSON.stringify([ + 'APPROVAL_REQUIRED', + 'DOCUMENT_UPDATED', + 'REQUEST_APPROVED', + 'REQUEST_REJECTED', + ]), + secret_hash: await bcrypt.hash('fire_webhook_secret', 10), is_active: true, }, { - id: secondCitizenUserId, // Use same ID for linking - digilocker_id: 'DL-GOA-CITIZEN-002', - name: 'Second Citizen', - email: 'citizen2@example.com', - phone: '+91-9876543215', - wallet_address: secondCitizenWallet.address, + id: uuidv4(), + department_id: tourismDeptId, + url: 'https://tourism.goa.gov.in/api/webhooks/gel', + events: JSON.stringify(['APPROVAL_REQUIRED', 'DOCUMENT_UPDATED', 'REQUEST_APPROVED']), + secret_hash: await bcrypt.hash('tourism_webhook_secret', 10), is_active: true, }, - ]); + { + id: uuidv4(), + department_id: municipalityId, + url: 'https://ccpanaji.goa.gov.in/api/webhooks/gel', + events: JSON.stringify([ + 'APPROVAL_REQUIRED', + 'DOCUMENT_UPDATED', + 'REQUEST_APPROVED', + 'REQUEST_REJECTED', + 'CHANGES_REQUESTED', + ]), + secret_hash: await bcrypt.hash('municipality_webhook_secret', 10), + is_active: true, + }, + { + id: uuidv4(), + department_id: healthDeptId, + url: 'https://health.goa.gov.in/api/webhooks/gel', + events: JSON.stringify(['APPROVAL_REQUIRED', 'REQUEST_APPROVED']), + secret_hash: await bcrypt.hash('health_webhook_secret', 10), + is_active: true, + }, + { + id: uuidv4(), + department_id: revenueDeptId, + url: 'https://revenue.goa.gov.in/api/webhooks/gel', + events: JSON.stringify(['APPROVAL_REQUIRED', 'DOCUMENT_UPDATED']), + secret_hash: await bcrypt.hash('revenue_webhook_secret', 10), + is_active: true, + }, + ]; - console.log('Seed data inserted successfully!'); - console.log(''); - console.log('Demo Accounts Created:'); - console.log('─────────────────────────────────────────'); - console.log('Admin: admin@goa.gov.in / Admin@123'); - console.log('Fire Dept: fire@goa.gov.in / Fire@123'); - console.log('Tourism: tourism@goa.gov.in / Tourism@123'); - console.log('Municipality: municipality@goa.gov.in / Municipality@123'); - console.log('Citizen 1: citizen@example.com / Citizen@123'); - console.log('Citizen 2: citizen2@example.com / Citizen@123'); - console.log('─────────────────────────────────────────'); - console.log('Departments:', departments.length); - console.log('Users:', users.length); - console.log('Wallets:', departmentWallets.length + userWallets.length); - console.log('Workflow created: RESORT_LICENSE'); + await knex('webhooks').insert(webhooks); + + // Print summary + console.log('\n========================================'); + console.log('GOA-GEL Demo Data Seeded Successfully!'); + console.log('========================================\n'); + + console.log('DEPARTMENTS (7):'); + console.log(' - Directorate of Transport, Goa'); + console.log(' - Directorate of Land Revenue, Goa'); + console.log(' - Goa Police Department'); + console.log(' - Goa Tourism Development Corporation'); + console.log(' - Directorate of Health Services, Goa'); + console.log(' - Goa Fire & Emergency Services'); + console.log(' - Corporation of the City of Panaji'); + + console.log('\nUSERS:'); + console.log(' Admin:'); + console.log(' - admin@goa.gov.in / Admin@123'); + console.log(' Department Officers:'); + console.log(' - transport@goa.gov.in / Transport@123'); + console.log(' - revenue@goa.gov.in / Revenue@123'); + console.log(' - police@goa.gov.in / Police@123'); + console.log(' - tourism@goa.gov.in / Tourism@123'); + console.log(' - health@goa.gov.in / Health@123'); + console.log(' - fire@goa.gov.in / Fire@123'); + console.log(' - municipality@goa.gov.in / Municipality@123'); + console.log(' Citizens (all password: Citizen@123):'); + console.log(' - rajesh.naik@example.com'); + console.log(' - priya.kamat@example.com'); + console.log(' - antonio.fernandes@example.com'); + console.log(' - sunita.desai@example.com'); + + console.log('\nAPPLICANTS: 12 (realistic Goan names)'); + + console.log('\nWORKFLOWS (5):'); + console.log(' - RESORT_LICENSE (4 stages: Fire -> Tourism+Municipality -> Health)'); + console.log(' - TRADE_LICENSE (2 stages: Municipality -> Fire)'); + console.log(' - BUILDING_PERMIT (3 stages: Revenue -> Municipality -> Fire)'); + console.log(' - FIRE_SAFETY_CERT (1 stage: Fire)'); + console.log(' - HEALTH_CERT (1 stage: Health)'); + + console.log('\nLICENSE REQUESTS (25):'); + console.log(' - APPROVED: 5'); + console.log(' - IN_REVIEW: 7'); + console.log(' - SUBMITTED: 5'); + console.log(' - REJECTED: 3'); + console.log(' - DRAFT: 3'); + console.log(' - PENDING_RESUBMISSION: 2'); + + console.log('\nDOCUMENTS: ' + documents.length + ' (4 per request)'); + console.log('APPROVALS: ' + approvals.length); + console.log('AUDIT LOGS: ' + auditLogs.length); + console.log('BLOCKCHAIN TRANSACTIONS: ' + blockchainTransactions.length); + console.log('WEBHOOKS: ' + webhooks.length); + + console.log('\n========================================\n'); } diff --git a/backend/src/main.ts b/backend/src/main.ts index 78f18ee..2d1c94c 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -11,7 +11,7 @@ import { LoggingInterceptor, CorrelationIdInterceptor } from './common/intercept async function bootstrap(): Promise { const logger = new Logger('Bootstrap'); - + const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log', 'debug', 'verbose'], }); @@ -28,7 +28,11 @@ async function bootstrap(): Promise { 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 { } }, 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 { 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 { 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 { displayRequestDuration: true, }, }); - + logger.log(`Swagger documentation available at http://localhost:${port}/api/docs`); } @@ -142,7 +150,7 @@ async function bootstrap(): Promise { 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); diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 4232ecd..24cda2a 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -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') diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index d09ad29..f94b192 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -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; + totalApprovals: number; + requestsByStatus: StatusCount[]; totalApplicants: number; activeApplicants: number; totalDepartments: number; activeDepartments: number; totalDocuments: number; totalBlockchainTransactions: number; - transactionsByStatus: Record; + 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 = {}; - 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 = {}; - 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 { - 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('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: [] }; + } + } } diff --git a/backend/src/modules/applicants/applicants.controller.ts b/backend/src/modules/applicants/applicants.controller.ts index 0740591..5282445 100644 --- a/backend/src/modules/applicants/applicants.controller.ts +++ b/backend/src/modules/applicants/applicants.controller.ts @@ -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); } } diff --git a/backend/src/modules/applicants/applicants.service.ts b/backend/src/modules/applicants/applicants.service.ts index f22f245..0c5b92a 100644 --- a/backend/src/modules/applicants/applicants.service.ts +++ b/backend/src/modules/applicants/applicants.service.ts @@ -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> { - 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 { + 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, + }; + } } diff --git a/backend/src/modules/approvals/approvals.controller.ts b/backend/src/modules/approvals/approvals.controller.ts index efb3f49..cc40202 100644 --- a/backend/src/modules/approvals/approvals.controller.ts +++ b/backend/src/modules/approvals/approvals.controller.ts @@ -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 { 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') diff --git a/backend/src/modules/approvals/approvals.service.ts b/backend/src/modules/approvals/approvals.service.ts index b0f1307..9b4e43b 100644 --- a/backend/src/modules/approvals/approvals.service.ts +++ b/backend/src/modules/approvals/approvals.service.ts @@ -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> { + 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 { + async revalidateApproval(approvalId: string, dto: RevalidateDto): Promise { 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 { - const approval = await this.approvalsRepository.query() + async canDepartmentApprove(requestId: string, departmentId: string): Promise { + 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 { - 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 { - 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 { - 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> { + async getApprovalCountByStatus(requestId: string): Promise> { const statuses = Object.values(ApprovalStatus); const counts: Record = {}; 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 { - 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 { - 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, diff --git a/backend/src/modules/approvals/dto/reject-request.dto.ts b/backend/src/modules/approvals/dto/reject-request.dto.ts index 9441287..bbb56a6 100644 --- a/backend/src/modules/approvals/dto/reject-request.dto.ts +++ b/backend/src/modules/approvals/dto/reject-request.dto.ts @@ -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() diff --git a/backend/src/modules/audit/audit.controller.ts b/backend/src/modules/audit/audit.controller.ts index 28a7165..526798e 100644 --- a/backend/src/modules/audit/audit.controller.ts +++ b/backend/src/modules/audit/audit.controller.ts @@ -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); } diff --git a/backend/src/modules/audit/audit.service.ts b/backend/src/modules/audit/audit.service.ts index df649c2..1c479d3 100644 --- a/backend/src/modules/audit/audit.service.ts +++ b/backend/src/modules/audit/audit.service.ts @@ -37,9 +37,7 @@ export class AuditService { ) {} async record(dto: CreateAuditLogDto): Promise { - 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), diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index e8af566..9feed7f 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -7,7 +7,7 @@ import { EmailPasswordLoginDto, LoginResponseDto, DigiLockerLoginResponseDto, - UserLoginResponseDto + UserLoginResponseDto, } from './dto'; @ApiTags('Auth') diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 75a6368..2008005 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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; diff --git a/backend/src/modules/auth/guards/roles.guard.ts b/backend/src/modules/auth/guards/roles.guard.ts index 0c8a7f1..bafe656 100644 --- a/backend/src/modules/auth/guards/roles.guard.ts +++ b/backend/src/modules/auth/guards/roles.guard.ts @@ -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); } } diff --git a/backend/src/modules/auth/strategies/api-key.strategy.ts b/backend/src/modules/auth/strategies/api-key.strategy.ts index b18dd6b..8ddfe8f 100644 --- a/backend/src/modules/auth/strategies/api-key.strategy.ts +++ b/backend/src/modules/auth/strategies/api-key.strategy.ts @@ -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, diff --git a/backend/src/modules/blockchain/services/approval-chain.service.ts b/backend/src/modules/blockchain/services/approval-chain.service.ts index 4c30fc8..6f68e04 100644 --- a/backend/src/modules/blockchain/services/approval-chain.service.ts +++ b/backend/src/modules/blockchain/services/approval-chain.service.ts @@ -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 { 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 { + async invalidateApproval(contractAddress: string, approvalId: string): Promise { 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 { 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 { + async getApprovalDetails(contractAddress: string, approvalId: string): Promise { 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); diff --git a/backend/src/modules/blockchain/services/blockchain-monitor.service.ts b/backend/src/modules/blockchain/services/blockchain-monitor.service.ts index 7b3e6ad..61bdb9c 100644 --- a/backend/src/modules/blockchain/services/blockchain-monitor.service.ts +++ b/backend/src/modules/blockchain/services/blockchain-monitor.service.ts @@ -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 { 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 { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise(resolve => setTimeout(resolve, ms)); } } diff --git a/backend/src/modules/blockchain/services/document-chain.service.ts b/backend/src/modules/blockchain/services/document-chain.service.ts index cc67173..b351bb6 100644 --- a/backend/src/modules/blockchain/services/document-chain.service.ts +++ b/backend/src/modules/blockchain/services/document-chain.service.ts @@ -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 { + async getLatestDocumentHash(contractAddress: string, documentId: string): Promise { try { const contract = this.web3Service.getContract(contractAddress, this.documentChainAbi) as any; diff --git a/backend/src/modules/blockchain/services/license-nft.service.ts b/backend/src/modules/blockchain/services/license-nft.service.ts index 88a44dd..228dc32 100644 --- a/backend/src/modules/blockchain/services/license-nft.service.ts +++ b/backend/src/modules/blockchain/services/license-nft.service.ts @@ -74,10 +74,7 @@ export class LicenseNFTService { } } - async getLicenseMetadata( - contractAddress: string, - tokenId: bigint, - ): Promise { + async getLicenseMetadata(contractAddress: string, tokenId: bigint): Promise { 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 { + async getTokenIdByRequest(contractAddress: string, requestId: string): Promise { 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 { + async verifyLicense(contractAddress: string, tokenId: bigint): Promise { try { const isValid = await this.isLicenseValid(contractAddress, tokenId); const metadata = await this.getLicenseMetadata(contractAddress, tokenId); diff --git a/backend/src/modules/blockchain/services/web3.service.ts b/backend/src/modules/blockchain/services/web3.service.ts index c1a8339..93b26d4 100644 --- a/backend/src/modules/blockchain/services/web3.service.ts +++ b/backend/src/modules/blockchain/services/web3.service.ts @@ -53,10 +53,7 @@ export class Web3Service implements OnModuleInit { return this.wallet; } - getContract( - address: string, - abi: ethers.InterfaceAbi, - ): T { + getContract(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; } } diff --git a/backend/src/modules/blockchain/wallet.service.ts b/backend/src/modules/blockchain/wallet.service.ts index c77e9c7..0fcfd1c 100644 --- a/backend/src/modules/blockchain/wallet.service.ts +++ b/backend/src/modules/blockchain/wallet.service.ts @@ -65,7 +65,10 @@ export class WalletService { /** * Get wallet by owner */ - async getWalletByOwner(ownerType: 'USER' | 'DEPARTMENT', ownerId: string): Promise { + async getWalletByOwner( + ownerType: 'USER' | 'DEPARTMENT', + ownerId: string, + ): Promise { return this.walletModel.query().findOne({ ownerType, ownerId, diff --git a/backend/src/modules/departments/departments.controller.ts b/backend/src/modules/departments/departments.controller.ts index 1b388f5..e7eda61 100644 --- a/backend/src/modules/departments/departments.controller.ts +++ b/backend/src/modules/departments/departments.controller.ts @@ -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 { - 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 { - 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 { - 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 { - 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` }; } } diff --git a/backend/src/modules/departments/departments.service.ts b/backend/src/modules/departments/departments.service.ts index 4bfb4aa..e9467a4 100644 --- a/backend/src/modules/departments/departments.service.ts +++ b/backend/src/modules/departments/departments.service.ts @@ -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> */ async findAll(query: PaginationDto): Promise> { - 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 */ - async updateWebhook( - id: string, - webhookUrl: string, - webhookSecret?: string, - ): Promise { + async updateWebhook(id: string, webhookUrl: string, webhookSecret?: string): Promise { 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 */ - async getStats( - code: string, - startDate?: Date, - endDate?: Date, - ): Promise { + async getStats(code: string, startDate?: Date, endDate?: Date): Promise { this.logger.debug( `Fetching statistics for department: ${code} from ${startDate} to ${endDate}`, ); diff --git a/backend/src/modules/departments/dto/create-department.dto.ts b/backend/src/modules/departments/dto/create-department.dto.ts index a4f89ff..bf0b150 100644 --- a/backend/src/modules/departments/dto/create-department.dto.ts +++ b/backend/src/modules/departments/dto/create-department.dto.ts @@ -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 { diff --git a/backend/src/modules/departments/dto/department-stats.dto.ts b/backend/src/modules/departments/dto/department-stats.dto.ts index d0924e4..49b597b 100644 --- a/backend/src/modules/departments/dto/department-stats.dto.ts +++ b/backend/src/modules/departments/dto/department-stats.dto.ts @@ -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; } } } diff --git a/backend/src/modules/departments/dto/update-department.dto.ts b/backend/src/modules/departments/dto/update-department.dto.ts index 938334e..107df44 100644 --- a/backend/src/modules/departments/dto/update-department.dto.ts +++ b/backend/src/modules/departments/dto/update-department.dto.ts @@ -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) { diff --git a/backend/src/modules/documents/documents.controller.ts b/backend/src/modules/documents/documents.controller.ts index 99a65d6..5bfebe9 100644 --- a/backend/src/modules/documents/documents.controller.ts +++ b/backend/src/modules/documents/documents.controller.ts @@ -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 { @@ -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 { - 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 { - 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, diff --git a/backend/src/modules/documents/documents.module.ts b/backend/src/modules/documents/documents.module.ts index 02daaaa..fc4ac80 100644 --- a/backend/src/modules/documents/documents.module.ts +++ b/backend/src/modules/documents/documents.module.ts @@ -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], diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts index b63a9a2..7e402a3 100644 --- a/backend/src/modules/documents/documents.service.ts +++ b/backend/src/modules/documents/documents.service.ts @@ -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 { 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 { 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(', ')}`, ); } } diff --git a/backend/src/modules/documents/dto/document-response.dto.ts b/backend/src/modules/documents/dto/document-response.dto.ts index efad8ba..79abd1f 100644 --- a/backend/src/modules/documents/dto/document-response.dto.ts +++ b/backend/src/modules/documents/dto/document-response.dto.ts @@ -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', }) diff --git a/backend/src/modules/documents/dto/upload-document.dto.ts b/backend/src/modules/documents/dto/upload-document.dto.ts index e6fdcb6..fac146e 100644 --- a/backend/src/modules/documents/dto/upload-document.dto.ts +++ b/backend/src/modules/documents/dto/upload-document.dto.ts @@ -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 { diff --git a/backend/src/modules/documents/services/minio.service.ts b/backend/src/modules/documents/services/minio.service.ts index 03f0c71..23932e1 100644 --- a/backend/src/modules/documents/services/minio.service.ts +++ b/backend/src/modules/documents/services/minio.service.ts @@ -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('MINIO_ACCESS_KEY', 'minioadmin'); const secretKey = this.configService.get('MINIO_SECRET_KEY', 'minioadmin'); - this.defaultBucket = this.configService.get( - 'MINIO_BUCKET', - 'goa-gel-documents', - ); + this.defaultBucket = this.configService.get('MINIO_BUCKET', 'goa-gel-documents'); this.region = this.configService.get('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 { + async getSignedUrl(bucket: string, path: string, expiresIn: number = 3600): Promise { 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 { + 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 { + 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; + } + } } diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index b846696..d3e253b 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -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'; diff --git a/backend/src/modules/requests/dto/create-request.dto.ts b/backend/src/modules/requests/dto/create-request.dto.ts index a05f7a2..ff071a0 100644 --- a/backend/src/modules/requests/dto/create-request.dto.ts +++ b/backend/src/modules/requests/dto/create-request.dto.ts @@ -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'; diff --git a/backend/src/modules/requests/requests.controller.ts b/backend/src/modules/requests/requests.controller.ts index 875aee5..9e17006 100644 --- a/backend/src/modules/requests/requests.controller.ts +++ b/backend/src/modules/requests/requests.controller.ts @@ -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 }); diff --git a/backend/src/modules/requests/requests.service.ts b/backend/src/modules/requests/requests.service.ts index 0980ef0..ec2b00c 100644 --- a/backend/src/modules/requests/requests.service.ts +++ b/backend/src/modules/requests/requests.service.ts @@ -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 = { - '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 { 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 { 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; + return { data: requests.results, total: requests.total }; } async submit(id: string): Promise { @@ -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.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 = { status: newStatus as LicenseRequestStatus }; diff --git a/backend/src/modules/webhooks/services/webhooks.service.ts b/backend/src/modules/webhooks/services/webhooks.service.ts index b3afda8..a91e056 100644 --- a/backend/src/modules/webhooks/services/webhooks.service.ts +++ b/backend/src/modules/webhooks/services/webhooks.service.ts @@ -19,9 +19,7 @@ export class WebhooksService { async register(departmentId: string, dto: CreateWebhookDto): Promise { 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 { 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> { + 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 { try { const webhook = await this.webhookRepository.query().findById(id); @@ -161,11 +171,15 @@ export class WebhooksService { } } - async getLogs(webhookId: string, pagination: PaginationDto): Promise> { + async getLogs( + webhookId: string, + pagination: PaginationDto, + ): Promise> { 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( diff --git a/backend/src/modules/webhooks/webhooks.controller.ts b/backend/src/modules/webhooks/webhooks.controller.ts index 9d3341c..18a3a56 100644 --- a/backend/src/modules/webhooks/webhooks.controller.ts +++ b/backend/src/modules/webhooks/webhooks.controller.ts @@ -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); } } diff --git a/backend/src/modules/workflows/dto/create-workflow.dto.ts b/backend/src/modules/workflows/dto/create-workflow.dto.ts index 97f9305..d682bc3 100644 --- a/backend/src/modules/workflows/dto/create-workflow.dto.ts +++ b/backend/src/modules/workflows/dto/create-workflow.dto.ts @@ -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'; diff --git a/backend/src/modules/workflows/dto/update-workflow.dto.ts b/backend/src/modules/workflows/dto/update-workflow.dto.ts index e5ad4e1..39f840b 100644 --- a/backend/src/modules/workflows/dto/update-workflow.dto.ts +++ b/backend/src/modules/workflows/dto/update-workflow.dto.ts @@ -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'; diff --git a/backend/src/modules/workflows/dto/workflow-stage.dto.ts b/backend/src/modules/workflows/dto/workflow-stage.dto.ts index bd5ed62..42f0171 100644 --- a/backend/src/modules/workflows/dto/workflow-stage.dto.ts +++ b/backend/src/modules/workflows/dto/workflow-stage.dto.ts @@ -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'; diff --git a/backend/src/modules/workflows/services/workflow-executor.service.ts b/backend/src/modules/workflows/services/workflow-executor.service.ts index 0d6ffba..71ee12a 100644 --- a/backend/src/modules/workflows/services/workflow-executor.service.ts +++ b/backend/src/modules/workflows/services/workflow-executor.service.ts @@ -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 { + async initializeWorkflow(requestId: string, workflowId: string): Promise { 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 { - 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 { - 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 { 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 { 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 { + async handleDocumentUpdate(requestId: string, documentId: string): Promise { 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 { + async canDepartmentApprove(requestId: string, departmentCode: string): Promise { 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); diff --git a/backend/src/modules/workflows/services/workflows.service.ts b/backend/src/modules/workflows/services/workflows.service.ts index f2f9e2b..b29fca1 100644 --- a/backend/src/modules/workflows/services/workflows.service.ts +++ b/backend/src/modules/workflows/services/workflows.service.ts @@ -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 { - 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, })), diff --git a/backend/src/modules/workflows/workflows.controller.ts b/backend/src/modules/workflows/workflows.controller.ts index 83dc1cd..5e20868 100644 --- a/backend/src/modules/workflows/workflows.controller.ts +++ b/backend/src/modules/workflows/workflows.controller.ts @@ -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') diff --git a/backend/src/modules/workflows/workflows.module.ts b/backend/src/modules/workflows/workflows.module.ts index 85d3343..f93e67a 100644 --- a/backend/src/modules/workflows/workflows.module.ts +++ b/backend/src/modules/workflows/workflows.module.ts @@ -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], diff --git a/blockchain-architecture.html b/blockchain-architecture.html deleted file mode 100644 index dd2e81a..0000000 --- a/blockchain-architecture.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - blockchain-architecture - - - - -

BLOCKCHAIN ARCHITECTURE

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

CONTAINER ARCHITECTURE

-
-graph TB - subgraph Client["Client Layer"] - WEB["🌐 Next.js 14 Frontend
shadcn/ui
Port: 3000"] - end - - subgraph API["API & Backend Layer"] - APIGW["📡 API Gateway
NestJS
Port: 3001"] - AUTH["🔐 Auth Service
API Key + Secret
(POC)"] - WORKFLOW["⚙️ Workflow Service
NestJS Module"] - APPROVAL["✅ Approval Service
NestJS Module"] - DOCUMENT["📄 Document Service
NestJS Module"] - end - - subgraph Data["Data Layer"] - DB["🗄️ PostgreSQL
Port: 5432
license_requests
approvals, documents
audit_logs"] - CACHE["⚡ Redis Cache
Port: 6379
Session, Workflow State"] - STORAGE["📦 MinIO
Port: 9000
Document Files
License PDFs"] - end - - subgraph Blockchain["Blockchain Layer"] - BESU["⛓️ Hyperledger Besu
QBFT Consensus
Port: 8545"] - CONTRACTS["📋 Smart Contracts
• LicenseRequestNFT
• ApprovalManager
• DepartmentRegistry
• WorkflowRegistry"] - BCDB["📚 Chain State
Account Balances
NFT Metadata"] - end - - subgraph Integrations["External Integrations"] - DIGILOCKER["📱 DigiLocker Mock
Document Verification"] - LEGACY["💼 Legacy Systems
Data Integration"] - WEBHOOK["🔔 Webhook Service
Event Notifications"] - end - - WEB -->|REST/GraphQL| APIGW - APIGW -->|Validate Token| AUTH - APIGW -->|Route Request| WORKFLOW - APIGW -->|Route Request| APPROVAL - APIGW -->|Route Request| DOCUMENT - - WORKFLOW -->|Read/Write| DB - WORKFLOW -->|Cache State| CACHE - WORKFLOW -->|Submit TX| BESU - - APPROVAL -->|Read/Write| DB - APPROVAL -->|Cache| CACHE - APPROVAL -->|Smart Contract Call| BESU - - DOCUMENT -->|Store Files| STORAGE - DOCUMENT -->|Hash Generation| DOCUMENT - DOCUMENT -->|Record Hash| BESU - - BESU -->|Execute| CONTRACTS - CONTRACTS -->|Update State| BCDB - - APIGW -->|Verify Docs| DIGILOCKER - APIGW -->|Query Legacy| LEGACY - APPROVAL -->|Send Event| WEBHOOK - - style WEB fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff - style APIGW fill:#3b82f6,stroke:#1e40af,stroke-width:2px,color:#fff - style AUTH fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff - style DB fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff - style STORAGE fill:#ec4899,stroke:#be123c,stroke-width:2px,color:#fff - style CACHE fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff - style BESU fill:#ef4444,stroke:#991b1b,stroke-width:2px,color:#fff - style CONTRACTS fill:#dc2626,stroke:#7f1d1d,stroke-width:2px,color:#fff - -
- - - \ No newline at end of file diff --git a/container-architecture.mermaid b/container-architecture.mermaid deleted file mode 100644 index d08b586..0000000 --- a/container-architecture.mermaid +++ /dev/null @@ -1,64 +0,0 @@ -graph TB - subgraph Client["Client Layer"] - WEB["🌐 Next.js 14 Frontend
shadcn/ui
Port: 3000"] - end - - subgraph API["API & Backend Layer"] - APIGW["📡 API Gateway
NestJS
Port: 3001"] - AUTH["🔐 Auth Service
API Key + Secret
(POC)"] - WORKFLOW["⚙️ Workflow Service
NestJS Module"] - APPROVAL["✅ Approval Service
NestJS Module"] - DOCUMENT["📄 Document Service
NestJS Module"] - end - - subgraph Data["Data Layer"] - DB["🗄️ PostgreSQL
Port: 5432
license_requests
approvals, documents
audit_logs"] - CACHE["⚡ Redis Cache
Port: 6379
Session, Workflow State"] - STORAGE["📦 MinIO
Port: 9000
Document Files
License PDFs"] - end - - subgraph Blockchain["Blockchain Layer"] - BESU["⛓️ Hyperledger Besu
QBFT Consensus
Port: 8545"] - CONTRACTS["📋 Smart Contracts
• LicenseRequestNFT
• ApprovalManager
• DepartmentRegistry
• WorkflowRegistry"] - BCDB["📚 Chain State
Account Balances
NFT Metadata"] - end - - subgraph Integrations["External Integrations"] - DIGILOCKER["📱 DigiLocker Mock
Document Verification"] - LEGACY["💼 Legacy Systems
Data Integration"] - WEBHOOK["🔔 Webhook Service
Event Notifications"] - end - - WEB -->|REST/GraphQL| APIGW - APIGW -->|Validate Token| AUTH - APIGW -->|Route Request| WORKFLOW - APIGW -->|Route Request| APPROVAL - APIGW -->|Route Request| DOCUMENT - - WORKFLOW -->|Read/Write| DB - WORKFLOW -->|Cache State| CACHE - WORKFLOW -->|Submit TX| BESU - - APPROVAL -->|Read/Write| DB - APPROVAL -->|Cache| CACHE - APPROVAL -->|Smart Contract Call| BESU - - DOCUMENT -->|Store Files| STORAGE - DOCUMENT -->|Hash Generation| DOCUMENT - DOCUMENT -->|Record Hash| BESU - - BESU -->|Execute| CONTRACTS - CONTRACTS -->|Update State| BCDB - - APIGW -->|Verify Docs| DIGILOCKER - APIGW -->|Query Legacy| LEGACY - APPROVAL -->|Send Event| WEBHOOK - - style WEB fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff - style APIGW fill:#3b82f6,stroke:#1e40af,stroke-width:2px,color:#fff - style AUTH fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff - style DB fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff - style STORAGE fill:#ec4899,stroke:#be123c,stroke-width:2px,color:#fff - style CACHE fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff - style BESU fill:#ef4444,stroke:#991b1b,stroke-width:2px,color:#fff - style CONTRACTS fill:#dc2626,stroke:#7f1d1d,stroke-width:2px,color:#fff diff --git a/convert-to-png.js b/convert-to-png.js deleted file mode 100644 index e014a73..0000000 --- a/convert-to-png.js +++ /dev/null @@ -1,235 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -// Create simple PNG placeholders with SVG conversion instructions -// Since mermaid-cli is not available, we'll create a comprehensive README - -const diagrams = [ - { file: 'system-context.mermaid', title: 'System Context Diagram' }, - { file: 'container-architecture.mermaid', title: 'Container Architecture' }, - { file: 'blockchain-architecture.mermaid', title: 'Blockchain Architecture' }, - { file: 'workflow-state-machine.mermaid', title: 'Workflow State Machine' }, - { file: 'data-flow.mermaid', title: 'Data Flow Diagram' }, - { file: 'deployment-architecture.mermaid', title: 'Deployment Architecture' } -]; - -let readme = `# Goa GEL Blockchain Document Verification Platform - Architecture Diagrams - -## Overview -This directory contains comprehensive architecture diagrams for the Goa Government E-License (GEL) Blockchain Document Verification Platform. - -## Diagrams - -`; - -diagrams.forEach(({ file, title }) => { - readme += `### ${title} -- **File:** \`${file}\` -- **Type:** Mermaid Diagram - -`; -}); - -readme += ` -## Converting Mermaid to PNG - -### Option 1: Online Converter -Visit https://mermaid.live and: -1. Click "Upload File" -2. Select each .mermaid file -3. Click the download icon to export as PNG - -### Option 2: Using Mermaid CLI (Local Installation) -\`\`\`bash -# Install locally -npm install --save-dev @mermaid-js/mermaid-cli - -# Convert all files -npx mmdc -i system-context.mermaid -o system-context.png -t dark -b transparent -npx mmdc -i container-architecture.mermaid -o container-architecture.png -t dark -b transparent -npx mmdc -i blockchain-architecture.mermaid -o blockchain-architecture.png -t dark -b transparent -npx mmdc -i workflow-state-machine.mermaid -o workflow-state-machine.png -t dark -b transparent -npx mmdc -i data-flow.mermaid -o data-flow.png -t dark -b transparent -npx mmdc -i deployment-architecture.mermaid -o deployment-architecture.png -t dark -b transparent -\`\`\` - -### Option 3: Using Docker -\`\`\`bash -docker run --rm -v $(pwd):/data mermaid/mermaid-cli:latest \\ - -i /data/system-context.mermaid \\ - -o /data/system-context.png \\ - -t dark -b transparent -\`\`\` - -### Option 4: Browser Method -Open each .html file in a web browser and: -1. Press F12 to open DevTools -2. Use Chrome DevTools to capture the diagram as an image -3. Or use a screenshot tool - -## Diagram Contents - -### 1. system-context.mermaid -**C4 Level 1 Context Diagram** -- Shows the GEL platform as a black box -- External actors: Citizens, Government Departments, Department Operators, Platform Operators -- External systems: DigiLocker Mock, Legacy Department Systems, National Blockchain Federation (future) - -### 2. container-architecture.mermaid -**C4 Level 2 Container Diagram** -- Frontend: Next.js 14 with shadcn/ui (Port 3000) -- Backend: NestJS API Gateway (Port 3001) -- Database: PostgreSQL (Port 5432) -- Cache: Redis (Port 6379) -- Storage: MinIO S3-compatible (Port 9000) -- Blockchain: Hyperledger Besu nodes -- Services: Auth, Workflow, Approval, Document - -### 3. blockchain-architecture.mermaid -**Blockchain Layer Deep Dive** -- 4 Hyperledger Besu Validator Nodes (QBFT Consensus) -- RPC Ports: 8545-8548 -- Smart Contracts: - - LicenseRequestNFT (ERC-721 Soulbound) - - ApprovalManager - - DepartmentRegistry - - WorkflowRegistry -- On-Chain vs Off-Chain Data Split -- Content Hashing (SHA-256) for Immutable Links - -### 4. workflow-state-machine.mermaid -**License Request Workflow States** -States: -- DRAFT: Initial local draft -- SUBMITTED: Hash recorded on blockchain -- IN_REVIEW: Multi-department approval -- PENDING_RESUBMISSION: Changes requested -- APPROVED: License granted, NFT minted -- REJECTED: Request denied -- REVOKED: License cancelled - -### 5. data-flow.mermaid -**Complete End-to-End Sequence** -11-Step Process: -1. License Request Submission -2. Document Upload & Hashing -3. Blockchain Recording -4. State Update to SUBMITTED -5. Route to Department 1 (Tourism) -6. Route to Department 2 (Fire Safety) - Parallel -7. Department 1 Approval -8. Department 2 Approval - Parallel -9. Final Approval Processing -10. Update Final State & Notifications -11. License Verification - -### 6. deployment-architecture.mermaid -**Docker Compose Deployment** -Services: -- Frontend: Next.js (Port 3000) -- Backend: NestJS (Port 3001) -- Database: PostgreSQL (Port 5432) -- Cache: Redis (Port 6379) -- Storage: MinIO (Port 9000, 9001) -- Blockchain: 4x Besu Validators (Ports 8545-8548) -- Monitoring: Prometheus (9090), Grafana (3000 alt) - -Volumes & Configuration Files - -## Key Technical Decisions - -### Blockchain -- **Platform:** Hyperledger Besu -- **Consensus:** QBFT (Quorum Byzantine Fault Tolerant) -- **Network Type:** Private Permissioned -- **Validators:** 4 nodes (requires 3/4 approval) -- **Block Time:** ~12 seconds - -### Tokens -- **Standard:** ERC-721 -- **Type:** Soulbound NFTs -- **Purpose:** Non-transferable license certificates -- **Metadata:** Immutable license details - -### Backend -- **Framework:** NestJS (TypeScript) -- **Database:** PostgreSQL -- **File Storage:** MinIO (S3-compatible) -- **Cache:** Redis - -### Frontend -- **Framework:** Next.js 14 -- **UI:** shadcn/ui -- **State Management:** React Context/TanStack Query -- **Styling:** Tailwind CSS - -### Authentication -- **POC Phase:** API Key + Secret -- **Future:** DigiLocker Integration (Mocked) - -## Architecture Benefits - -1. **Immutable Records**: Blockchain ensures license records cannot be tampered with -2. **Multi-Department Workflows**: Parallel or sequential approvals based on license type -3. **Transparent Verification**: Anyone can verify license authenticity on blockchain -4. **Scalability**: Off-chain document storage with on-chain hashing -5. **Auditability**: Complete audit trail of all state changes -6. **Privacy**: Permissioned network with department access controls -7. **Future-Proof**: NFT standard enables future interoperability - -## Viewing Instructions - -1. **Mermaid Live** (Easiest): https://mermaid.live - - Copy-paste content from .mermaid files - - Instant preview and export - -2. **HTML Files** (Built-in Browser): - - Open system-context.html (and others) in any web browser - - Uses CDN-hosted mermaid.js for rendering - -3. **PNG Export**: - - Follow the conversion options above - - Recommended: Use mermaid-cli or online converter - -## File Listing - -\`\`\` -/sessions/cool-elegant-faraday/mnt/Goa-GEL/ -├── system-context.mermaid -├── system-context.html -├── container-architecture.mermaid -├── container-architecture.html -├── blockchain-architecture.mermaid -├── blockchain-architecture.html -├── workflow-state-machine.mermaid -├── workflow-state-machine.html -├── data-flow.mermaid -├── data-flow.html -├── deployment-architecture.mermaid -├── deployment-architecture.html -├── convert.js -├── convert-to-png.js -└── README.md -\`\`\` - -## Next Steps - -1. Review all diagrams to understand system architecture -2. Use these for documentation and stakeholder presentations -3. Convert to PNG/SVG for inclusion in technical documentation -4. Share with team for feedback and refinement - ---- - -**Generated:** 2026-02-03 -**Platform:** Goa GEL Blockchain Document Verification -**Version:** POC 1.0 -`; - -fs.writeFileSync(path.join(__dirname, 'README.md'), readme); -console.log('README.md created successfully!'); -console.log('\nDiagrams created:'); -diagrams.forEach(d => { - console.log(` - ${d.file}`); - console.log(` └─ ${d.file.replace('.mermaid', '.html')} (viewable in browser)`); -}); diff --git a/convert.js b/convert.js deleted file mode 100644 index 8c6fa0c..0000000 --- a/convert.js +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); - -// Simple solution: Create a convert-to-html script that uses mermaid.js -// Since we can't install globally, we'll create an HTML file for each diagram - -const diagrams = [ - 'system-context.mermaid', - 'container-architecture.mermaid', - 'blockchain-architecture.mermaid', - 'workflow-state-machine.mermaid', - 'data-flow.mermaid', - 'deployment-architecture.mermaid' -]; - -const dir = __dirname; - -diagrams.forEach(diagram => { - const mermaidPath = path.join(dir, diagram); - const htmlPath = path.join(dir, diagram.replace('.mermaid', '.html')); - - if (fs.existsSync(mermaidPath)) { - const content = fs.readFileSync(mermaidPath, 'utf8'); - - const html = ` - - - - - ${diagram.replace('.mermaid', '')} - - - - -

${diagram.replace('.mermaid', '').replace(/-/g, ' ').toUpperCase()}

-
-${content} -
- - -`; - - fs.writeFileSync(htmlPath, html); - console.log(`Created: ${htmlPath}`); - } -}); - -console.log('HTML conversion complete!'); -console.log('Open each .html file in a browser and use browser tools to export as PNG'); diff --git a/create_presentation.js b/create_presentation.js deleted file mode 100644 index 32c032f..0000000 --- a/create_presentation.js +++ /dev/null @@ -1,1426 +0,0 @@ -const pptxgen = require("pptxgenjs"); - -let pres = new pptxgen(); -pres.layout = "LAYOUT_16x9"; -pres.author = "Goa Government"; -pres.title = "Goa GEL Blockchain Document Verification Platform"; - -// Color palette - Professional government/tech -const colors = { - primary: "0F4C81", // Deep blue - secondary: "1B7BAA", // Teal blue - accent: "00B4D8", // Bright cyan - lightBg: "F0F4F8", // Light blue-gray - darkBg: "0B2E4D", // Dark navy - white: "FFFFFF", - text: "1A1A1A", - lightText: "666666", - success: "06A77D", -}; - -// Helper function for fresh shadow objects -const makeShadow = () => ({ - type: "outer", - blur: 6, - offset: 2, - color: "000000", - opacity: 0.12, -}); - -// Utility: Add title with accent bar -function addTitleWithBar(slide, title, x = 0.5, y = 0.4, w = 9, barColor = colors.accent) { - slide.addShape(pres.shapes.RECTANGLE, { - x: x, - y: y, - w: 0.08, - h: 0.6, - fill: { color: barColor }, - line: { type: "none" }, - }); - - slide.addText(title, { - x: x + 0.15, - y: y, - w: w - 0.2, - h: 0.6, - fontSize: 40, - bold: true, - color: colors.white, - fontFace: "Calibri", - valign: "middle", - margin: 0, - }); -} - -// ===== SLIDE 1: TITLE SLIDE ===== -let slide1 = pres.addSlide(); -slide1.background = { color: colors.darkBg }; - -slide1.addText("Goa GEL", { - x: 0.5, - y: 1.5, - w: 9, - h: 0.8, - fontSize: 60, - bold: true, - color: colors.accent, - align: "center", - fontFace: "Calibri", -}); - -slide1.addText("Blockchain-Based Document Verification Platform", { - x: 0.5, - y: 2.4, - w: 9, - h: 0.6, - fontSize: 36, - color: colors.white, - align: "center", - fontFace: "Calibri", -}); - -slide1.addText("for Government of Goa", { - x: 0.5, - y: 3.1, - w: 9, - h: 0.4, - fontSize: 20, - color: colors.lightBg, - align: "center", - fontFace: "Calibri", -}); - -slide1.addText("Technical Architecture Overview", { - x: 0.5, - y: 4.3, - w: 9, - h: 0.4, - fontSize: 18, - italic: true, - color: colors.accent, - align: "center", - fontFace: "Calibri", -}); - -// ===== SLIDE 2: AGENDA ===== -let slide2 = pres.addSlide(); -slide2.background = { color: colors.lightBg }; - -addTitleWithBar(slide2, "Agenda"); - -const agendaItems = [ - "Problem Statement", - "Solution Overview", - "NBF Alignment", - "System Architecture", - "Blockchain Architecture", - "Smart Contracts", - "Technology Stack", - "Workflow Engine", - "Data Flow", - "Security Architecture", - "Deployment & POC Scope", - "Success Criteria & Timeline", -]; - -slide2.addText( - agendaItems.map((item, idx) => ({ - text: item, - options: { - bullet: true, - breakLine: idx < agendaItems.length - 1, - }, - })), - { - x: 1, - y: 1.2, - w: 8, - h: 3.8, - fontSize: 16, - color: colors.text, - fontFace: "Calibri", - } -); - -// ===== SLIDE 3: PROBLEM STATEMENT ===== -let slide3 = pres.addSlide(); -slide3.background = { color: colors.white }; - -addTitleWithBar(slide3, "Problem Statement", 0.5, 0.3); - -const problems = [ - { - title: "Fragmented Mechanisms", - desc: "Multiple online/offline document processes", - }, - { - title: "Lack of Trust", - desc: "No transparent verification mechanism", - }, - { - title: "Poor Traceability", - desc: "Document history and approvals unclear", - }, - { - title: "Tampering Risks", - desc: "No protection against document modification", - }, -]; - -let yPos = 1.3; -problems.forEach((problem, idx) => { - const bgColor = [colors.primary, colors.secondary, colors.accent, "FF6B6B"][idx]; - - slide3.addShape(pres.shapes.RECTANGLE, { - x: 0.5 + idx * 2.25, - y: yPos, - w: 2.1, - h: 3.5, - fill: { color: bgColor }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - slide3.addText(problem.title, { - x: 0.65 + idx * 2.25, - y: yPos + 0.3, - w: 1.8, - h: 1, - fontSize: 16, - bold: true, - color: colors.white, - align: "center", - fontFace: "Calibri", - }); - - slide3.addText(problem.desc, { - x: 0.65 + idx * 2.25, - y: yPos + 1.5, - w: 1.8, - h: 1.6, - fontSize: 13, - color: colors.white, - align: "center", - valign: "middle", - fontFace: "Calibri", - }); -}); - -// ===== SLIDE 4: SOLUTION OVERVIEW ===== -let slide4 = pres.addSlide(); -slide4.background = { color: colors.lightBg }; - -addTitleWithBar(slide4, "Solution Overview: GEL Platform"); - -const solutions = [ - { - title: "Single Ledger", - icon: "✓", - }, - { - title: "Multi-Stakeholder Consensus", - icon: "✓", - }, - { - title: "End-to-End Traceability", - icon: "✓", - }, - { - title: "REST API Interoperability", - icon: "✓", - }, -]; - -let xPos = 0.7; -solutions.forEach((sol) => { - slide4.addShape(pres.shapes.RECTANGLE, { - x: xPos, - y: 1.4, - w: 2.0, - h: 2.8, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - slide4.addText(sol.icon, { - x: xPos, - y: 1.6, - w: 2.0, - h: 0.5, - fontSize: 48, - color: colors.success, - align: "center", - fontFace: "Calibri", - }); - - slide4.addText(sol.title, { - x: xPos + 0.1, - y: 2.2, - w: 1.8, - h: 1.8, - fontSize: 14, - bold: true, - color: colors.text, - align: "center", - valign: "middle", - fontFace: "Calibri", - }); - - xPos += 2.15; -}); - -// ===== SLIDE 5: NBF ALIGNMENT ===== -let slide5 = pres.addSlide(); -slide5.background = { color: colors.white }; - -addTitleWithBar(slide5, "National Blockchain Framework Alignment"); - -slide5.addShape(pres.shapes.RECTANGLE, { - x: 0.5, - y: 1.2, - w: 9, - h: 3.8, - fill: { color: colors.lightBg }, - line: { type: "none" }, -}); - -slide5.addText( - [ - { text: "NBF Document Chain Integration Path\n", options: { bold: true, fontSize: 18, breakLine: true } }, - { - text: - "GEL Platform designed with future NBF compliance in mind. Current implementation follows recommended blockchain patterns and can be seamlessly integrated with National Blockchain Framework when available.\n\n", - options: { fontSize: 14, breakLine: true }, - }, - { - text: "Key Alignment Features:", - options: { bold: true, fontSize: 14, breakLine: true }, - }, - { - text: "QBFT consensus mechanism", - options: { bullet: true, breakLine: true }, - }, - { text: "Validator node architecture", options: { bullet: true, breakLine: true } }, - { text: "Decentralized governance model", options: { bullet: true, breakLine: true } }, - { text: "Cross-chain interoperability", options: { bullet: true } }, - ], - { - x: 0.8, - y: 1.4, - w: 8.4, - h: 3.4, - fontSize: 14, - color: colors.text, - fontFace: "Calibri", - } -); - -// ===== SLIDE 6: SYSTEM ARCHITECTURE ===== -let slide6 = pres.addSlide(); -slide6.background = { color: colors.lightBg }; - -addTitleWithBar(slide6, "System Architecture - High Level"); - -const components = [ - { name: "Frontend\n(Next.js 14)", x: 0.6, color: colors.primary }, - { name: "Backend API\n(NestJS)", x: 2.8, color: colors.secondary }, - { name: "Smart Contracts\n(ERC-721)", x: 5.0, color: colors.accent }, - { name: "Database\n(PostgreSQL)", x: 7.2, color: "FF6B6B" }, -]; - -components.forEach((comp) => { - slide6.addShape(pres.shapes.RECTANGLE, { - x: comp.x, - y: 1.2, - w: 1.8, - h: 1.2, - fill: { color: comp.color }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - slide6.addText(comp.name, { - x: comp.x, - y: 1.2, - w: 1.8, - h: 1.2, - fontSize: 12, - bold: true, - color: colors.white, - align: "center", - valign: "middle", - fontFace: "Calibri", - }); - - slide6.addShape(pres.shapes.LINE, { - x: comp.x + 1.8, - y: 1.8, - w: 0.8, - h: 0, - line: { color: colors.text, width: 2 }, - }); -}); - -// Remove last line -slide6.addShape(pres.shapes.RECTANGLE, { - x: 8.8, - y: 1.7, - w: 0.4, - h: 0.2, - fill: { color: colors.lightBg }, - line: { type: "none" }, -}); - -slide6.addText("Storage Layer (MinIO) | Blockchain (Hyperledger Besu)", { - x: 0.6, - y: 2.8, - w: 8.8, - h: 0.4, - fontSize: 13, - color: colors.text, - align: "center", - fontFace: "Calibri", -}); - -slide6.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: 3.5, - w: 8.8, - h: 1.5, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), -}); - -slide6.addText( - [ - { text: "Architectural Principles\n", options: { bold: true, fontSize: 12, breakLine: true } }, - { text: "Microservices: Independent, scalable components", options: { bullet: true, breakLine: true } }, - { text: "API-First: RESTful interfaces for all services", options: { bullet: true, breakLine: true } }, - { text: "Event-Driven: Async communication between layers", options: { bullet: true } }, - ], - { - x: 0.9, - y: 3.6, - w: 8.2, - h: 1.3, - fontSize: 11, - color: colors.text, - fontFace: "Calibri", - } -); - -// ===== SLIDE 7: BLOCKCHAIN ARCHITECTURE ===== -let slide7 = pres.addSlide(); -slide7.background = { color: colors.white }; - -addTitleWithBar(slide7, "Blockchain Architecture - Deep Dive"); - -slide7.addShape(pres.shapes.RECTANGLE, { - x: 0.5, - y: 1.2, - w: 4.2, - h: 3.8, - fill: { color: colors.lightBg }, - line: { type: "none" }, -}); - -slide7.addText( - [ - { text: "Hyperledger Besu\n\n", options: { bold: true, fontSize: 14, breakLine: true } }, - { text: "QBFT Consensus", options: { bullet: true, breakLine: true } }, - { text: "4 Validator Nodes", options: { bullet: true, breakLine: true } }, - { text: "Immediate Finality", options: { bullet: true, breakLine: true } }, - { text: "Enterprise-Grade", options: { bullet: true, breakLine: true } }, - { text: "EVM Compatible", options: { bullet: true } }, - ], - { - x: 0.8, - y: 1.4, - w: 3.6, - h: 3.4, - fontSize: 13, - color: colors.text, - fontFace: "Calibri", - } -); - -slide7.addShape(pres.shapes.RECTANGLE, { - x: 5.2, - y: 1.2, - w: 4.2, - h: 3.8, - fill: { color: colors.primary }, - line: { type: "none" }, -}); - -slide7.addText( - [ - { text: "Network Topology\n\n", options: { bold: true, fontSize: 14, color: colors.white, breakLine: true } }, - { - text: "Validator 1", - options: { bullet: true, color: colors.white, breakLine: true }, - }, - { - text: "Validator 2", - options: { bullet: true, color: colors.white, breakLine: true }, - }, - { - text: "Validator 3", - options: { bullet: true, color: colors.white, breakLine: true }, - }, - { - text: "Validator 4", - options: { bullet: true, color: colors.white, breakLine: true }, - }, - { - text: "Public RPC Nodes", - options: { bullet: true, color: colors.white }, - }, - ], - { - x: 5.5, - y: 1.4, - w: 3.6, - h: 3.4, - fontSize: 13, - color: colors.white, - fontFace: "Calibri", - } -); - -// ===== SLIDE 8: SMART CONTRACTS ===== -let slide8 = pres.addSlide(); -slide8.background = { color: colors.lightBg }; - -addTitleWithBar(slide8, "Smart Contracts - NFT-Based Approach"); - -const contracts = [ - { - name: "LicenseRequestNFT", - desc: "ERC-721 Soulbound", - y: 1.3, - }, - { - name: "ApprovalManager", - desc: "Multi-stage approvals", - y: 2.4, - }, - { - name: "DepartmentRegistry", - desc: "Department governance", - y: 3.5, - }, - { - name: "WorkflowRegistry", - desc: "Process orchestration", - y: 4.6, - }, -]; - -contracts.forEach((contract) => { - slide8.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: contract.y, - w: 0.08, - h: 0.7, - fill: { color: colors.accent }, - line: { type: "none" }, - }); - - slide8.addText(contract.name, { - x: 0.8, - y: contract.y, - w: 3.5, - h: 0.35, - fontSize: 14, - bold: true, - color: colors.text, - fontFace: "Calibri", - valign: "top", - margin: 0, - }); - - slide8.addText(contract.desc, { - x: 0.8, - y: contract.y + 0.35, - w: 3.5, - h: 0.35, - fontSize: 12, - color: colors.lightText, - fontFace: "Calibri", - valign: "top", - margin: 0, - }); - - slide8.addShape(pres.shapes.RECTANGLE, { - x: 4.5, - y: contract.y, - w: 5.0, - h: 0.7, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - const details = [ - ["Status Tracking", "Access Control", "Event Logging"], - ["Sequential Flow", "Parallel Stages", "Conditional Logic"], - ["Department IDs", "Role Binding", "Approval Rights"], - ["Stage Management", "Versioning", "State Transitions"], - ]; - - slide8.addText(details[contracts.indexOf(contract)].join(" | "), { - x: 4.7, - y: contract.y + 0.15, - w: 4.6, - h: 0.4, - fontSize: 11, - color: colors.text, - align: "left", - fontFace: "Calibri", - }); -}); - -// ===== SLIDE 9: TECHNOLOGY STACK ===== -let slide9 = pres.addSlide(); -slide9.background = { color: colors.white }; - -addTitleWithBar(slide9, "Technology Stack"); - -const stackLayers = [ - { - title: "Frontend", - tech: "Next.js 14, React, TypeScript", - y: 1.3, - }, - { - title: "Backend", - tech: "NestJS, TypeScript, Node.js", - y: 2.3, - }, - { - title: "Database", - tech: "PostgreSQL (relational), Redis (caching)", - y: 3.3, - }, - { - title: "Storage", - tech: "MinIO (S3-compatible object storage)", - y: 4.3, - }, -]; - -stackLayers.forEach((layer, idx) => { - const colors_alt = [colors.primary, colors.secondary, colors.accent, "FF6B6B"]; - - slide9.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: layer.y, - w: 8.8, - h: 0.8, - fill: { color: colors_alt[idx] }, - line: { type: "none" }, - }); - - slide9.addText(layer.title, { - x: 0.9, - y: layer.y + 0.15, - w: 2.0, - h: 0.5, - fontSize: 14, - bold: true, - color: colors.white, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - slide9.addText(layer.tech, { - x: 3.2, - y: layer.y + 0.15, - w: 6.0, - h: 0.5, - fontSize: 13, - color: colors.white, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); -}); - -// ===== SLIDE 10: WORKFLOW ENGINE ===== -let slide10 = pres.addSlide(); -slide10.background = { color: colors.lightBg }; - -addTitleWithBar(slide10, "Workflow Engine"); - -slide10.addText("Multi-Department Approval System", { - x: 0.5, - y: 1.2, - w: 9, - h: 0.3, - fontSize: 16, - bold: true, - color: colors.text, - fontFace: "Calibri", -}); - -const stages = [ - "Submission", - "Department Review", - "Senior Approval", - "Final Authorization", - "NFT Minted", -]; - -let stageX = 0.8; -stages.forEach((stage, idx) => { - const boxW = 1.6; - slide10.addShape(pres.shapes.RECTANGLE, { - x: stageX, - y: 1.8, - w: boxW, - h: 0.8, - fill: { color: idx < 3 ? colors.primary : idx === 3 ? colors.secondary : colors.success }, - line: { type: "none" }, - }); - - slide10.addText(stage, { - x: stageX, - y: 1.8, - w: boxW, - h: 0.8, - fontSize: 11, - bold: true, - color: colors.white, - align: "center", - valign: "middle", - fontFace: "Calibri", - }); - - if (idx < stages.length - 1) { - slide10.addShape(pres.shapes.LINE, { - x: stageX + boxW, - y: 2.2, - w: 0.35, - h: 0, - line: { color: colors.text, width: 2 }, - }); - } - - stageX += boxW + 0.45; -}); - -const features = [ - { - title: "Sequential & Parallel", - desc: "Configure approval stages as sequential or parallel", - }, - { - title: "Document Versioning", - desc: "Track all document revisions through workflow", - }, - { - title: "Auto-Invalidation", - desc: "Automatically invalidate approvals upon document change", - }, -]; - -let featureY = 3.0; -features.forEach((feature) => { - slide10.addShape(pres.shapes.RECTANGLE, { - x: 0.8, - y: featureY, - w: 0.06, - h: 0.4, - fill: { color: colors.accent }, - line: { type: "none" }, - }); - - slide10.addText(feature.title, { - x: 0.95, - y: featureY, - w: 3.0, - h: 0.25, - fontSize: 12, - bold: true, - color: colors.text, - fontFace: "Calibri", - margin: 0, - }); - - slide10.addText(feature.desc, { - x: 0.95, - y: featureY + 0.28, - w: 3.0, - h: 0.35, - fontSize: 11, - color: colors.lightText, - fontFace: "Calibri", - margin: 0, - }); - - featureY += 1.35; -}); - -// Right side - diagram -slide10.addShape(pres.shapes.RECTANGLE, { - x: 4.5, - y: 3.0, - w: 4.8, - h: 2.2, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), -}); - -slide10.addText( - [ - { text: "Approval Flow Example\n", options: { bold: true, fontSize: 12, breakLine: true } }, - { text: "1. Submit Document", options: { bullet: true, breakLine: true } }, - { text: "2. Department A Reviews", options: { bullet: true, breakLine: true } }, - { text: "3. Department B Reviews (parallel)", options: { bullet: true, breakLine: true } }, - { text: "4. Director Approves", options: { bullet: true, breakLine: true } }, - { text: "5. NFT Minted on Chain", options: { bullet: true } }, - ], - { - x: 4.8, - y: 3.2, - w: 4.2, - h: 1.8, - fontSize: 11, - color: colors.text, - fontFace: "Calibri", - } -); - -// ===== SLIDE 11: DATA FLOW ===== -let slide11 = pres.addSlide(); -slide11.background = { color: colors.white }; - -addTitleWithBar(slide11, "Data Flow - License Approval Process"); - -const flowSteps = [ - { - num: "1", - title: "Submission", - desc: "User submits license request with documents", - }, - { - num: "2", - title: "Review", - desc: "Department staff reviews document", - }, - { - num: "3", - title: "Approval", - desc: "Document approved by authority", - }, - { - num: "4", - title: "NFT Minting", - desc: "Approved document minted as NFT", - }, -]; - -let flowX = 0.7; -flowSteps.forEach((step, idx) => { - const boxW = 2.0; - - // Step box - slide11.addShape(pres.shapes.RECTANGLE, { - x: flowX, - y: 1.5, - w: boxW, - h: 1.2, - fill: { color: colors.primary }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - slide11.addText(step.num, { - x: flowX + 0.2, - y: 1.6, - w: 0.5, - h: 0.4, - fontSize: 20, - bold: true, - color: colors.accent, - fontFace: "Calibri", - margin: 0, - }); - - slide11.addText(step.title, { - x: flowX + 0.8, - y: 1.6, - w: 1.0, - h: 0.4, - fontSize: 12, - bold: true, - color: colors.white, - fontFace: "Calibri", - margin: 0, - }); - - slide11.addText(step.desc, { - x: flowX + 0.2, - y: 2.1, - w: 1.6, - h: 0.5, - fontSize: 10, - color: colors.lightBg, - fontFace: "Calibri", - margin: 0, - }); - - // Arrow - if (idx < flowSteps.length - 1) { - slide11.addShape(pres.shapes.LINE, { - x: flowX + boxW, - y: 2.1, - w: 0.4, - h: 0, - line: { color: colors.text, width: 2 }, - }); - - slide11.addText("→", { - x: flowX + boxW + 0.1, - y: 1.95, - w: 0.2, - h: 0.3, - fontSize: 16, - color: colors.text, - align: "center", - fontFace: "Calibri", - }); - } - - flowX += boxW + 0.5; -}); - -// Database integration info -slide11.addShape(pres.shapes.RECTANGLE, { - x: 0.7, - y: 3.2, - w: 8.6, - h: 1.9, - fill: { color: colors.lightBg }, - line: { type: "none" }, -}); - -slide11.addText( - [ - { text: "Data Storage Strategy\n", options: { bold: true, fontSize: 13, breakLine: true } }, - { text: "Document Metadata (PostgreSQL): Status, timestamps, approver info", options: { bullet: true, breakLine: true } }, - { text: "Document Files (MinIO): Original and versioned documents", options: { bullet: true, breakLine: true } }, - { text: "Blockchain Record (Besu): Immutable approval chain and NFT reference", options: { bullet: true, breakLine: true } }, - { text: "Event Log (Event Bus): Real-time updates for UI and integrations", options: { bullet: true } }, - ], - { - x: 0.95, - y: 3.35, - w: 8.1, - h: 1.6, - fontSize: 11, - color: colors.text, - fontFace: "Calibri", - } -); - -// ===== SLIDE 12: SECURITY ARCHITECTURE ===== -let slide12 = pres.addSlide(); -slide12.background = { color: colors.lightBg }; - -addTitleWithBar(slide12, "Security Architecture"); - -const securityLayers = [ - { - layer: "API Level", - features: ["API Key Authentication", "JWT Tokens", "Rate Limiting"], - }, - { - layer: "Wallet Level", - features: ["Custodial Wallet Management", "Private Key Protection", "Transaction Signing"], - }, - { - layer: "Integration Level", - features: ["Mock DigiLocker (POC)", "Production: Real DigiLocker", "Digital Signature Verification"], - }, -]; - -let secY = 1.3; -securityLayers.forEach((sec) => { - slide12.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: secY, - w: 8.8, - h: 0.9, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - slide12.addText(sec.layer, { - x: 0.85, - y: secY + 0.15, - w: 1.8, - h: 0.6, - fontSize: 13, - bold: true, - color: colors.primary, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - slide12.addText(sec.features.join(" • "), { - x: 2.7, - y: secY + 0.15, - w: 6.5, - h: 0.6, - fontSize: 11, - color: colors.text, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - secY += 1.1; -}); - -// Security principles -slide12.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: 4.6, - w: 8.8, - h: 0.8, - fill: { color: colors.accent }, - line: { type: "none" }, -}); - -slide12.addText( - "Zero Trust Architecture | Defense in Depth | Least Privilege Access", - { - x: 0.85, - y: 4.65, - w: 8.3, - h: 0.7, - fontSize: 12, - bold: true, - color: colors.white, - align: "center", - valign: "middle", - fontFace: "Calibri", - margin: 0, - } -); - -// ===== SLIDE 13: DEPLOYMENT ARCHITECTURE ===== -let slide13 = pres.addSlide(); -slide13.background = { color: colors.white }; - -addTitleWithBar(slide13, "Deployment Architecture"); - -slide13.addText("Docker Compose for POC", { - x: 0.5, - y: 1.2, - w: 9, - h: 0.3, - fontSize: 14, - bold: true, - color: colors.text, - fontFace: "Calibri", -}); - -const services = [ - { - name: "Frontend", - port: ":3000", - y: 1.7, - }, - { - name: "API Service", - port: ":3001", - y: 2.4, - }, - { - name: "PostgreSQL", - port: ":5432", - y: 3.1, - }, - { - name: "MinIO Storage", - port: ":9000", - y: 3.8, - }, - { - name: "Besu Node 1", - port: ":8545", - y: 4.5, - }, -]; - -services.forEach((svc) => { - slide13.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: svc.y, - w: 3.5, - h: 0.5, - fill: { color: colors.primary }, - line: { type: "none" }, - }); - - slide13.addText(svc.name, { - x: 0.85, - y: svc.y + 0.05, - w: 2.0, - h: 0.4, - fontSize: 12, - bold: true, - color: colors.white, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - slide13.addText(svc.port, { - x: 3.0, - y: svc.y + 0.05, - w: 1.0, - h: 0.4, - fontSize: 11, - color: colors.accent, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - slide13.addShape(pres.shapes.RECTANGLE, { - x: 4.3, - y: svc.y, - w: 5.1, - h: 0.5, - fill: { color: colors.lightBg }, - line: { type: "none" }, - }); -}); - -slide13.addText("Health Checks & Auto-Recovery | Persistent Volumes | Network Isolation", { - x: 0.6, - y: 5.3, - w: 8.8, - h: 0.35, - fontSize: 11, - color: colors.text, - align: "center", - fontFace: "Calibri", -}); - -// ===== SLIDE 14: POC SCOPE ===== -let slide14 = pres.addSlide(); -slide14.background = { color: colors.lightBg }; - -addTitleWithBar(slide14, "POC Scope - Resort License Demo"); - -// In-scope column -slide14.addShape(pres.shapes.RECTANGLE, { - x: 0.5, - y: 1.2, - w: 4.4, - h: 4.0, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), -}); - -slide14.addText("In-Scope Features", { - x: 0.7, - y: 1.35, - w: 4.0, - h: 0.4, - fontSize: 14, - bold: true, - color: colors.success, - fontFace: "Calibri", -}); - -slide14.addText( - [ - { text: "End-to-end license workflow", options: { bullet: true, breakLine: true } }, - { text: "3-department approval chain", options: { bullet: true, breakLine: true } }, - { text: "Document upload & storage", options: { bullet: true, breakLine: true } }, - { text: "NFT minting on Besu", options: { bullet: true, breakLine: true } }, - { text: "API endpoints", options: { bullet: true, breakLine: true } }, - { text: "Basic UI workflows", options: { bullet: true } }, - ], - { - x: 0.8, - y: 1.85, - w: 4.0, - h: 3.1, - fontSize: 11, - color: colors.text, - fontFace: "Calibri", - } -); - -// Out-of-scope column -slide14.addShape(pres.shapes.RECTANGLE, { - x: 5.1, - y: 1.2, - w: 4.4, - h: 4.0, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), -}); - -slide14.addText("Out-of-Scope (Future)", { - x: 5.3, - y: 1.35, - w: 4.0, - h: 0.4, - fontSize: 14, - bold: true, - color: "FF6B6B", - fontFace: "Calibri", -}); - -slide14.addText( - [ - { text: "Real DigiLocker integration", options: { bullet: true, breakLine: true } }, - { text: "Multi-language support", options: { bullet: true, breakLine: true } }, - { text: "Advanced analytics", options: { bullet: true, breakLine: true } }, - { text: "Mobile applications", options: { bullet: true, breakLine: true } }, - { text: "Scaling to production", options: { bullet: true, breakLine: true } }, - { text: "External API integrations", options: { bullet: true } }, - ], - { - x: 5.3, - y: 1.85, - w: 4.0, - h: 3.1, - fontSize: 11, - color: colors.text, - fontFace: "Calibri", - } -); - -// ===== SLIDE 15: SUCCESS CRITERIA ===== -let slide15 = pres.addSlide(); -slide15.background = { color: colors.white }; - -addTitleWithBar(slide15, "Success Criteria"); - -const criteria = [ - { - title: "Complete Workflow", - metric: "100%", - desc: "End-to-end document flow functional", - }, - { - title: "Department Integration", - metric: "3", - desc: "3 departments successfully integrated", - }, - { - title: "Version Control", - metric: "100%", - desc: "Document versioning operational", - }, - { - title: "Visual Builder", - metric: "MVP", - desc: "Workflow configuration UI ready", - }, -]; - -let critX = 0.55; -criteria.forEach((crit) => { - const boxW = 2.15; - - slide15.addShape(pres.shapes.RECTANGLE, { - x: critX, - y: 1.3, - w: boxW, - h: 3.5, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - slide15.addText(crit.metric, { - x: critX, - y: 1.5, - w: boxW, - h: 0.7, - fontSize: 36, - bold: true, - color: colors.accent, - align: "center", - fontFace: "Calibri", - }); - - slide15.addText(crit.title, { - x: critX + 0.15, - y: 2.3, - w: boxW - 0.3, - h: 0.8, - fontSize: 12, - bold: true, - color: colors.text, - align: "center", - fontFace: "Calibri", - }); - - slide15.addText(crit.desc, { - x: critX + 0.15, - y: 3.2, - w: boxW - 0.3, - h: 1.4, - fontSize: 10, - color: colors.lightText, - align: "center", - valign: "middle", - fontFace: "Calibri", - }); - - critX += boxW + 0.15; -}); - -// ===== SLIDE 16: TIMELINE & NEXT STEPS ===== -let slide16 = pres.addSlide(); -slide16.background = { color: colors.lightBg }; - -addTitleWithBar(slide16, "Timeline & Next Steps"); - -const timeline = [ - { phase: "Phase 1", duration: "Week 1-2", milestone: "Architecture Setup" }, - { phase: "Phase 2", duration: "Week 3-4", milestone: "Core Development" }, - { phase: "Phase 3", duration: "Week 5-6", milestone: "Integration & Testing" }, - { phase: "Phase 4", duration: "Week 7-8", milestone: "POC Demonstration" }, -]; - -let timelineY = 1.4; -timeline.forEach((item, idx) => { - slide16.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: timelineY, - w: 8.8, - h: 0.75, - fill: { color: colors.white }, - line: { type: "none" }, - shadow: makeShadow(), - }); - - slide16.addText(item.phase, { - x: 0.8, - y: timelineY + 0.12, - w: 1.2, - h: 0.5, - fontSize: 12, - bold: true, - color: colors.primary, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - slide16.addText(item.duration, { - x: 2.2, - y: timelineY + 0.12, - w: 1.8, - h: 0.5, - fontSize: 11, - color: colors.text, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - slide16.addText(item.milestone, { - x: 4.2, - y: timelineY + 0.12, - w: 5.0, - h: 0.5, - fontSize: 11, - color: colors.text, - valign: "middle", - fontFace: "Calibri", - margin: 0, - }); - - timelineY += 0.95; -}); - -// Next steps -slide16.addShape(pres.shapes.RECTANGLE, { - x: 0.6, - y: 4.5, - w: 8.8, - h: 0.85, - fill: { color: colors.accent }, - line: { type: "none" }, -}); - -slide16.addText( - [ - { text: "Next Steps: ", options: { bold: true, breakLine: true } }, - { text: "Stakeholder approval", options: { bullet: true, breakLine: true } }, - { text: "Resource allocation", options: { bullet: true, breakLine: true } }, - { text: "Project kickoff", options: { bullet: true } }, - ], - { - x: 0.9, - y: 4.55, - w: 8.2, - h: 0.75, - fontSize: 11, - color: colors.white, - fontFace: "Calibri", - } -); - -// ===== SLIDE 17: Q&A ===== -let slide17 = pres.addSlide(); -slide17.background = { color: colors.darkBg }; - -slide17.addShape(pres.shapes.RECTANGLE, { - x: 2.0, - y: 1.8, - w: 6.0, - h: 2.0, - fill: { color: colors.primary }, - line: { type: "none" }, - shadow: makeShadow(), -}); - -slide17.addText("Questions?", { - x: 2.0, - y: 2.2, - w: 6.0, - h: 0.8, - fontSize: 54, - bold: true, - color: colors.accent, - align: "center", - fontFace: "Calibri", -}); - -slide17.addText("Thank you for your attention", { - x: 2.0, - y: 3.1, - w: 6.0, - h: 0.4, - fontSize: 18, - color: colors.white, - align: "center", - fontFace: "Calibri", -}); - -// Save presentation -pres.writeFile({ fileName: "/sessions/cool-elegant-faraday/mnt/Goa-GEL/Goa-GEL-Architecture-Presentation.pptx" }); - -console.log("Presentation created successfully!"); diff --git a/data-flow.html b/data-flow.html deleted file mode 100644 index bdb2caa..0000000 --- a/data-flow.html +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - data-flow - - - - -

DATA FLOW

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

DEPLOYMENT ARCHITECTURE

-
-graph TB - subgraph Host["Host Machine
Docker Compose Environment"] - Docker["🐳 Docker Engine"] - end - - subgraph Services["Services & Containers"] - subgraph Frontend_svc["Frontend Service"] - NJS["Next.js 14
Container
Port: 3000
Volume: ./frontend"] - end - - subgraph API_svc["Backend API Service"] - NESTJS["NestJS
Container
Port: 3001
Volume: ./backend
Env: DB_HOST,
BLOCKCHAIN_RPC"] - end - - subgraph Database_svc["Database Service"] - PG["PostgreSQL 15
Container
Port: 5432
Volume: postgres_data
POSTGRES_DB: goa_gel
POSTGRES_USER: gel_user"] - end - - subgraph Cache_svc["Cache Service"] - REDIS["Redis 7
Container
Port: 6379
Volume: redis_data"] - end - - subgraph Storage_svc["File Storage Service"] - MINIO["MinIO
Container
Port: 9000 API
Port: 9001 Console
Volume: minio_data
Access: minioadmin
Secret: minioadmin"] - end - - subgraph Blockchain_svc["Blockchain Network"] - BESU1["Besu Validator 1
Container
Port: 8545 RPC
Port: 30303 P2P
Volume: besu_data_1"] - BESU2["Besu Validator 2
Container
Port: 8546 RPC
Port: 30304 P2P
Volume: besu_data_2"] - BESU3["Besu Validator 3
Container
Port: 8547 RPC
Port: 30305 P2P
Volume: besu_data_3"] - BESU4["Besu Validator 4
Container
Port: 8548 RPC
Port: 30306 P2P
Volume: besu_data_4"] - end - - subgraph Monitoring_svc["Monitoring & Logging"] - PROMETHEUS["Prometheus
Port: 9090"] - GRAFANA["Grafana
Port: 3000 Alt
Volume: grafana_storage"] - end - end - - subgraph Network["Docker Network"] - COMPOSE_NET["gel-network
Driver: bridge"] - end - - subgraph Volumes["Named Volumes"] - PG_VOL["postgres_data"] - REDIS_VOL["redis_data"] - MINIO_VOL["minio_data"] - BESU_VOL1["besu_data_1"] - BESU_VOL2["besu_data_2"] - BESU_VOL3["besu_data_3"] - BESU_VOL4["besu_data_4"] - GRAFANA_VOL["grafana_storage"] - end - - subgraph Config["Configuration Files"] - COMPOSE["docker-compose.yml"] - ENV[".env
BLOCKCHAIN_RPC
DB_PASSWORD
API_SECRET_KEY"] - BESU_CONFIG["besu/config.toml
genesis.json
ibft_config.toml"] - end - - Docker -->|Run| Services - Services -->|Connect via| COMPOSE_NET - - NJS -->|HTTP Client| NESTJS - NESTJS -->|SQL Query| PG - NESTJS -->|Cache| REDIS - NESTJS -->|S3 API| MINIO - NESTJS -->|RPC Call| BESU1 - - BESU1 -->|Peer| BESU2 - BESU1 -->|Peer| BESU3 - BESU1 -->|Peer| BESU4 - BESU2 -->|Peer| BESU3 - BESU2 -->|Peer| BESU4 - BESU3 -->|Peer| BESU4 - - PG -->|Store| PG_VOL - REDIS -->|Store| REDIS_VOL - MINIO -->|Store| MINIO_VOL - BESU1 -->|Store| BESU_VOL1 - BESU2 -->|Store| BESU_VOL2 - BESU3 -->|Store| BESU_VOL3 - BESU4 -->|Store| BESU_VOL4 - GRAFANA -->|Store| GRAFANA_VOL - - PROMETHEUS -->|Scrape| NESTJS - PROMETHEUS -->|Scrape| BESU1 - GRAFANA -->|Query| PROMETHEUS - - ENV -->|Configure| NESTJS - ENV -->|Configure| PG - BESU_CONFIG -->|Configure| BESU1 - BESU_CONFIG -->|Configure| BESU2 - BESU_CONFIG -->|Configure| BESU3 - BESU_CONFIG -->|Configure| BESU4 - - style Host fill:#1f2937,stroke:#111827,stroke-width:2px,color:#fff - style Services fill:#374151,stroke:#1f2937,stroke-width:2px,color:#fff - style Blockchain_svc fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff - style Network fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff - style Volumes fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff - style Config fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff - -
- - - \ No newline at end of file diff --git a/deployment-architecture.mermaid b/deployment-architecture.mermaid deleted file mode 100644 index c85486b..0000000 --- a/deployment-architecture.mermaid +++ /dev/null @@ -1,102 +0,0 @@ -graph TB - subgraph Host["Host Machine
Docker Compose Environment"] - Docker["🐳 Docker Engine"] - end - - subgraph Services["Services & Containers"] - subgraph Frontend_svc["Frontend Service"] - NJS["Next.js 14
Container
Port: 3000
Volume: ./frontend"] - end - - subgraph API_svc["Backend API Service"] - NESTJS["NestJS
Container
Port: 3001
Volume: ./backend
Env: DB_HOST,
BLOCKCHAIN_RPC"] - end - - subgraph Database_svc["Database Service"] - PG["PostgreSQL 15
Container
Port: 5432
Volume: postgres_data
POSTGRES_DB: goa_gel
POSTGRES_USER: gel_user"] - end - - subgraph Cache_svc["Cache Service"] - REDIS["Redis 7
Container
Port: 6379
Volume: redis_data"] - end - - subgraph Storage_svc["File Storage Service"] - MINIO["MinIO
Container
Port: 9000 API
Port: 9001 Console
Volume: minio_data
Access: minioadmin
Secret: minioadmin"] - end - - subgraph Blockchain_svc["Blockchain Network"] - BESU1["Besu Validator 1
Container
Port: 8545 RPC
Port: 30303 P2P
Volume: besu_data_1"] - BESU2["Besu Validator 2
Container
Port: 8546 RPC
Port: 30304 P2P
Volume: besu_data_2"] - BESU3["Besu Validator 3
Container
Port: 8547 RPC
Port: 30305 P2P
Volume: besu_data_3"] - BESU4["Besu Validator 4
Container
Port: 8548 RPC
Port: 30306 P2P
Volume: besu_data_4"] - end - - subgraph Monitoring_svc["Monitoring & Logging"] - PROMETHEUS["Prometheus
Port: 9090"] - GRAFANA["Grafana
Port: 3000 Alt
Volume: grafana_storage"] - end - end - - subgraph Network["Docker Network"] - COMPOSE_NET["gel-network
Driver: bridge"] - end - - subgraph Volumes["Named Volumes"] - PG_VOL["postgres_data"] - REDIS_VOL["redis_data"] - MINIO_VOL["minio_data"] - BESU_VOL1["besu_data_1"] - BESU_VOL2["besu_data_2"] - BESU_VOL3["besu_data_3"] - BESU_VOL4["besu_data_4"] - GRAFANA_VOL["grafana_storage"] - end - - subgraph Config["Configuration Files"] - COMPOSE["docker-compose.yml"] - ENV[".env
BLOCKCHAIN_RPC
DB_PASSWORD
API_SECRET_KEY"] - BESU_CONFIG["besu/config.toml
genesis.json
ibft_config.toml"] - end - - Docker -->|Run| Services - Services -->|Connect via| COMPOSE_NET - - NJS -->|HTTP Client| NESTJS - NESTJS -->|SQL Query| PG - NESTJS -->|Cache| REDIS - NESTJS -->|S3 API| MINIO - NESTJS -->|RPC Call| BESU1 - - BESU1 -->|Peer| BESU2 - BESU1 -->|Peer| BESU3 - BESU1 -->|Peer| BESU4 - BESU2 -->|Peer| BESU3 - BESU2 -->|Peer| BESU4 - BESU3 -->|Peer| BESU4 - - PG -->|Store| PG_VOL - REDIS -->|Store| REDIS_VOL - MINIO -->|Store| MINIO_VOL - BESU1 -->|Store| BESU_VOL1 - BESU2 -->|Store| BESU_VOL2 - BESU3 -->|Store| BESU_VOL3 - BESU4 -->|Store| BESU_VOL4 - GRAFANA -->|Store| GRAFANA_VOL - - PROMETHEUS -->|Scrape| NESTJS - PROMETHEUS -->|Scrape| BESU1 - GRAFANA -->|Query| PROMETHEUS - - ENV -->|Configure| NESTJS - ENV -->|Configure| PG - BESU_CONFIG -->|Configure| BESU1 - BESU_CONFIG -->|Configure| BESU2 - BESU_CONFIG -->|Configure| BESU3 - BESU_CONFIG -->|Configure| BESU4 - - style Host fill:#1f2937,stroke:#111827,stroke-width:2px,color:#fff - style Services fill:#374151,stroke:#1f2937,stroke-width:2px,color:#fff - style Blockchain_svc fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff - style Network fill:#06b6d4,stroke:#0891b2,stroke-width:2px,color:#fff - style Volumes fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff - style Config fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff diff --git a/docker-compose.yml b/docker-compose.yml index 1451173..c9a8d59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,22 @@ version: '3.8' +# ============================================================================== +# Goa GEL Platform - Docker Compose +# ============================================================================== +# +# Quick Start (no config needed): +# docker-compose up -d +# +# Production: Copy .env.example to .env and update security values +# +# Services: +# - Frontend: http://localhost:4200 +# - API: http://localhost:3001 +# - Blockscout: http://localhost:4000 +# - MinIO: http://localhost:9001 (admin console) +# +# ============================================================================== + services: # ================================ # PostgreSQL Database @@ -11,9 +28,9 @@ services: ports: - "5432:5432" environment: - - POSTGRES_DB=goa_gel_platform - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres_secure_password + POSTGRES_DB: goa_gel_platform + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-postgres_dev_password} volumes: - postgres_data:/var/lib/postgresql/data networks: @@ -55,8 +72,8 @@ services: - "9000:9000" - "9001:9001" environment: - - MINIO_ROOT_USER=minioadmin - - MINIO_ROOT_PASSWORD=minioadmin_secure + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minio_dev_password} command: server /data --console-address ":9001" volumes: - minio_data:/data @@ -115,7 +132,7 @@ services: environment: POSTGRES_DB: blockscout POSTGRES_USER: blockscout - POSTGRES_PASSWORD: blockscout_secure + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-blockscout_dev_password} volumes: - blockscout_db_data:/var/lib/postgresql/data networks: @@ -136,7 +153,7 @@ services: ports: - "4000:4000" environment: - DATABASE_URL: postgresql://blockscout:blockscout_secure@blockscout-db:5432/blockscout + DATABASE_URL: postgresql://blockscout:${DATABASE_PASSWORD:-blockscout_dev_password}@blockscout-db:5432/blockscout ETHEREUM_JSONRPC_VARIANT: besu ETHEREUM_JSONRPC_HTTP_URL: http://besu-node-1:8545 ETHEREUM_JSONRPC_WS_URL: ws://besu-node-1:8546 @@ -155,7 +172,7 @@ services: POOL_SIZE: 80 POOL_SIZE_API: 10 ECTO_USE_SSL: "false" - SECRET_KEY_BASE: RMgI4C1HSkxsEjdhtGMfwAHfyT6CKWXOgzCboJflfSm4jeAlic52io05KB6mqzc5 + SECRET_KEY_BASE: ${BLOCKSCOUT_SECRET_KEY_BASE:-RMgI4C1HSkxsEjdhtGMfwAHfyT6CKWXOgzCboJflfSm4jeAlic52io05KB6mqzc5} PORT: 4000 DISABLE_EXCHANGE_RATES: "true" SHOW_TXS_CHART: "true" @@ -188,29 +205,40 @@ services: ports: - "3001:3001" environment: - - NODE_ENV=production - - PORT=3001 - - DATABASE_HOST=postgres - - DATABASE_PORT=5432 - - DATABASE_NAME=goa_gel_platform - - DATABASE_USER=postgres - - DATABASE_PASSWORD=postgres_secure_password - - REDIS_HOST=redis - - REDIS_PORT=6379 - - MINIO_ENDPOINT=minio - - MINIO_PORT=9000 - - MINIO_ACCESS_KEY=minioadmin - - MINIO_SECRET_KEY=minioadmin_secure - - MINIO_BUCKET_DOCUMENTS=goa-gel-documents - - BESU_RPC_URL=http://besu-node-1:8545 - - BESU_CHAIN_ID=1337 - - BESU_NETWORK_ID=2024 - - CONTRACT_ADDRESS_LICENSE_NFT=${CONTRACT_ADDRESS_LICENSE_NFT:-} - - CONTRACT_ADDRESS_APPROVAL_MANAGER=${CONTRACT_ADDRESS_APPROVAL_MANAGER:-} - - CONTRACT_ADDRESS_DEPARTMENT_REGISTRY=${CONTRACT_ADDRESS_DEPARTMENT_REGISTRY:-} - - CONTRACT_ADDRESS_WORKFLOW_REGISTRY=${CONTRACT_ADDRESS_WORKFLOW_REGISTRY:-} - - PLATFORM_WALLET_PRIVATE_KEY=${PLATFORM_WALLET_PRIVATE_KEY:-} - - JWT_SECRET=${JWT_SECRET:-your-super-secure-jwt-secret-key-min-32-chars-long} + # Application + NODE_ENV: ${NODE_ENV:-production} + PORT: 3001 + # CORS - set to frontend URL for remote access + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:4200} + # Database (must match postgres service) + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_NAME: goa_gel_platform + DATABASE_USER: postgres + DATABASE_PASSWORD: ${DATABASE_PASSWORD:-postgres_dev_password} + # Redis + REDIS_HOST: redis + REDIS_PORT: 6379 + # MinIO (must match minio service) + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minio_dev_password} + MINIO_BUCKET_DOCUMENTS: goa-gel-documents + # Blockchain + BESU_RPC_URL: http://besu-node-1:8545 + BESU_CHAIN_ID: 1337 + BESU_NETWORK_ID: 2024 + # Smart Contracts (populated after blockchain contract deployment) + CONTRACT_ADDRESS_LICENSE_NFT: ${CONTRACT_ADDRESS_LICENSE_NFT:-} + CONTRACT_ADDRESS_APPROVAL_MANAGER: ${CONTRACT_ADDRESS_APPROVAL_MANAGER:-} + CONTRACT_ADDRESS_DEPARTMENT_REGISTRY: ${CONTRACT_ADDRESS_DEPARTMENT_REGISTRY:-} + CONTRACT_ADDRESS_WORKFLOW_REGISTRY: ${CONTRACT_ADDRESS_WORKFLOW_REGISTRY:-} + PLATFORM_WALLET_PRIVATE_KEY: ${PLATFORM_WALLET_PRIVATE_KEY:-} + # Security + JWT_SECRET: ${JWT_SECRET:-dev_jwt_secret_change_in_production_min32chars} + # Misc + FORCE_RESEED: ${FORCE_RESEED:-false} depends_on: postgres: condition: service_healthy @@ -223,7 +251,6 @@ services: networks: - goa-gel-network volumes: - - ./backend/.env:/app/.env - api_data:/app/data healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/v1/health"] @@ -243,6 +270,11 @@ services: restart: unless-stopped ports: - "4200:80" + environment: + # Runtime API URL - browsers need the public/external URL to reach the API + # For local: http://localhost:3001/api/v1 + # For remote: http://:3001/api/v1 or https://api.yourdomain.com/api/v1 + API_BASE_URL: ${API_BASE_URL:-http://localhost:3001/api/v1} depends_on: api: condition: service_healthy @@ -255,7 +287,7 @@ services: retries: 3 # ================================ - # Documentation Service + # Documentation Service (Optional) # ================================ documentation: build: @@ -267,6 +299,8 @@ services: - "8080:80" networks: - goa-gel-network + profiles: + - docs # Only starts with: docker-compose --profile docs up healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost/"] interval: 30s diff --git a/Goa-GEL-Architecture-Document.docx b/docs/Goa-GEL-Architecture-Document.docx similarity index 100% rename from Goa-GEL-Architecture-Document.docx rename to docs/Goa-GEL-Architecture-Document.docx diff --git a/Goa-GEL-Architecture-Document.pdf b/docs/Goa-GEL-Architecture-Document.pdf similarity index 100% rename from Goa-GEL-Architecture-Document.pdf rename to docs/Goa-GEL-Architecture-Document.pdf diff --git a/Goa-GEL-Architecture-Presentation.pptx b/docs/Goa-GEL-Architecture-Presentation.pptx similarity index 100% rename from Goa-GEL-Architecture-Presentation.pptx rename to docs/Goa-GEL-Architecture-Presentation.pptx diff --git a/fixes-prompt.md b/fixes-prompt.md deleted file mode 100644 index 43e0a02..0000000 --- a/fixes-prompt.md +++ /dev/null @@ -1,408 +0,0 @@ -# Claude Code Prompt: Frontend Overhaul — Blockchain Document Verification Platform (Angular) - -## Context - -You are working on the **Blockchain-based Document Verification Platform** for the **Government of Goa, India**. This is an Angular project that is already integrated with backend APIs but has significant UI/UX issues and buggy code. The backend APIs are being fixed in a parallel terminal — your job is **exclusively the frontend**. - -This platform handles multi-department approval workflows for licenses/permits (starting with Resort License POC). Departments include Fire Department, Tourism Department, and Municipality. Each department has a custodial blockchain wallet managed by the platform. - -**This is a demo-critical project. The UI must look world-class — think enterprise crypto/Web3 dashboard meets government platform. We need to impress government stakeholders.** - ---- - -## Your Mission - -### 1. Audit & Fix the Existing Angular Codebase -- Scan the entire frontend project for errors, broken imports, misconfigured modules, and failing builds -- Fix all TypeScript compilation errors, template errors, and runtime issues -- Ensure `ng serve` runs cleanly with zero errors and zero warnings -- Fix all existing API integrations — ensure HTTP calls, interceptors, auth headers (API Key + X-Department-Code), and error handling are working correctly -- Fix any broken routing, guards, lazy loading issues - -### 2. Complete UI/UX Overhaul — World-Class Design (DBIM & GIGW 3.0 Compliant) - -**⚠️ MANDATORY COMPLIANCE: India's Digital Brand Identity Manual (DBIM v3.0, Jan 2025) & GIGW 3.0** - -This is a Government of India / Government of Goa platform. It MUST comply with the official DBIM (Digital Brand Identity Manual) published by MeitY and the GIGW 3.0 (Guidelines for Indian Government Websites and Apps). Below are the extracted requirements: - -**Color Palette — DBIM Primary Colour Group + Functional Palette:** - -The DBIM requires each government org to pick ONE primary colour group. For a blockchain/technology platform under Government of Goa, select the **Blue colour group** from the DBIM primary palette. This aligns with the "Deep Blue" (#1D0A69) used for Gov.In websites and gives the modern tech feel we need. - -``` -DBIM PRIMARY COLOUR GROUP — BLUE (selected for this project): -├── Key Colour (darkest): Use for footer background, primary headers, sidebar -├── Mid variants: Use for primary buttons, active states, links -├── Light variants: Use for hover states, card accents, subtle backgrounds - -DBIM FUNCTIONAL PALETTE (mandatory for all govt platforms): -├── Background Primary: #FFFFFF (Inclusive White) — page backgrounds -├── Background Secondary: #EBEAEA (Linen) — highlight sections, card backgrounds, quote blocks -├── Text on Light BG: #150202 (Deep Earthy Brown) — NOT black, this is the official text color -├── Text on Dark BG: #FFFFFF (Inclusive White) -├── State Emblem on Light: #000000 (Black) -├── State Emblem on Dark: #FFFFFF (White) -├── Deep Blue (Gov.In): #1D0A69 — distinct government identity color -├── Success Status: #198754 (Liberty Green) — approved, confirmed -├── Warning Status: #FFC107 (Mustard Yellow) — pending, in-review -├── Error Status: #DC3545 (Coral Red) — rejected, failed -├── Information Status: #0D6EFD (Blue) — also for hyperlinks -├── Grey 01: #C6C6C6 -├── Grey 02: #8E8E8E -├── Grey 03: #606060 -``` - -**IMPORTANT COLOR RULES:** -- The DBIM allows gradients of any two variants from the selected colour group -- Hyperlinks must use #0D6EFD (DBIM Blue) or the key colour of the selected group -- Footer MUST be in the key colour (darkest shade) of the selected primary group -- Status colors are FIXED by DBIM — do not use custom colors for success/warning/error -- Text color on light backgrounds must be #150202 (Deep Earthy Brown), NOT pure black - -**Design Philosophy:** -- **Light theme primary** with professional government institutional aesthetics (DBIM mandates #FFFFFF as primary page background). Use the blue colour group for headers, sidebars, and accent areas to create the modern tech/blockchain feel -- For internal dashboards (department portal, admin portal), you MAY use a darker sidebar/nav with light content area — this is common in enterprise SaaS and not prohibited by DBIM -- Clean, modern, data-dense dashboard layouts -- Subtle card elevation with #EBEAEA (Linen) backgrounds for card sections -- Goa Government State Emblem + "Government of Goa" header (MANDATORY — see Logo section below) -- National Emblem of India (Ashoka Lions) at the top per DBIM lockup guidelines -- Smooth micro-animations (route transitions, card hovers, status changes) -- Fully responsive (desktop-first, but mobile must work for demo) -- WCAG 2.1 AA compliant contrast ratios (GIGW 3.0 mandates this) - -**Logo & Header — DBIM Mandatory Requirements:** -- Since this is a State Government platform, use **DBIM Lockup Style appropriate for State Government** -- Display the **Goa State Emblem** (Vriksha Deep / diya lamp with coconut leaves, Ashoka Lions on top, supported by two hands) prominently in the header -- Sanskrit motto: "सर्वे भद्राणि पश्यन्तु मा कश्चिद् दुःखमाप्नुयात्" (may appear in emblem) -- Text: "Government of Goa" below or beside the emblem -- Platform name: "Blockchain Document Verification Platform" as secondary text -- Header must include: Logo lockup, search bar, language selection (अ | A), accessibility controls, navigation menu -- On dark backgrounds use white emblem; on white backgrounds use black emblem - -**Footer — DBIM Mandatory Elements:** -- Footer MUST be in the darkest shade of the selected blue colour group -- Must include: Website Policies, Sitemap, Related Links, Help, Feedback, Social Media Links, Last Updated On -- Must state lineage: "This platform belongs to Government of Goa, India" -- Powered by / Technology credits may be included - -**Typography — DBIM Mandatory:** -- Font: **Noto Sans** (DBIM mandates this for ALL Government of India digital platforms — it supports all Indian scripts) -- Desktop scale: H1=36px, H2=24px, H3/Subtitle=20px, P1=16px, P2=14px, Small=12px -- Mobile scale: H1=24px, H2=20px, H3=16px, P1=14px, P2=12px, Small=10px -- Weights: Bold, Semi Bold, Medium, Regular -- Body text must be left-aligned -- Tables: left-aligned text, right-aligned numbers, center-aligned column names -- Line height: 1.2 to 1.5 times the type size -- NO all-caps for long sentences or paragraphs (DBIM rule) -- British English throughout (GIGW 3.0 requirement) - -**Icons — DBIM Guidelines:** -- Use icons from the DBIM Toolkit icon bank (https://dbimtoolkit.digifootprint.gov.in) where available -- Pick ONE icon style and stick with it: either Line icons or Filled icons (not mixed) -- Icons must be in the key colour (darkest shade) of selected group or Inclusive White -- Available sizes: standardized with 2px padding as per DBIM spec -- For icons not in DBIM toolkit, use **Lucide icons** or **Phosphor icons** but maintain consistent style -- Icons for significant actions MUST have text labels alongside them -- Include tooltips for all icons - -**Accessibility — GIGW 3.0 / WCAG 2.1 AA Mandatory:** -- Screen reader support with proper ARIA labels -- Keyboard navigation for all interactive elements -- Skip to main content link -- Accessibility controls in header (text size adjustment, contrast toggle) -- Alt text for all images (max 140 characters, no "image of..." prefixes) -- Proper heading hierarchy (H1, H2, H3) -- Sufficient color contrast (4.5:1 for normal text, 3:1 for large text) -- No content conveyed through color alone — use icons + text + color - -**Forms — DBIM Form Guidelines:** -- Instructions at the start of each form -- Fields arranged vertically (one per line) -- Mandatory fields marked with asterisk (*) or "Required" -- Multi-step forms for complex workflows (stepper UI) -- Labels above fields (not inline for complex forms), clickable labels -- Primary buttons prominent, secondary buttons less visual weight -- Validation feedback before submission -- Radio buttons instead of dropdowns for ≤6 options - -**CSS Framework & Tooling:** -- Use **Tailwind CSS** as the primary utility framework — configure custom theme with DBIM color tokens -- Extend Tailwind config with the exact DBIM hex values above as custom colors (e.g., `dbim-brown: '#150202'`, `dbim-linen: '#EBEAEA'`, `dbim-success: '#198754'`, etc.) -- Use **Angular Material** or **PrimeNG** for complex components (data tables, dialogs, steppers, file upload) — pick whichever is already partially in use, or install PrimeNG if starting fresh -- Custom CSS/SCSS for subtle card effects, gradients within the blue colour group, and animations -- Google Fonts: **Noto Sans** (mandatory per DBIM) — load all needed weights (400, 500, 600, 700) - -**Balancing Government Compliance with Modern Blockchain Aesthetic:** -The DBIM gives us a professional, trustworthy foundation. Layer the blockchain/Web3 feel through: -- Data visualization (charts, timelines, transaction hashes) -- Wallet cards with gradient backgrounds (using blue colour group variants) -- Blockchain transaction badges, hash displays, block explorers -- Modern micro-animations and transitions -- Dense, information-rich dashboards -- The blue colour group naturally gives a tech/blockchain feel while being DBIM compliant - -### 3. Core Pages & Components to Build/Rebuild - -#### A. **Login / Auth Page** -- Mock login for applicants -- Department login with API Key -- Admin login -- Animated background (subtle particle/grid animation or gradient mesh) -- Goa Government emblem + platform name prominent - -#### B. **Applicant Portal** - -**Dashboard:** -- Summary cards: Total Requests, Pending, Approved, Rejected (with animated counters) -- Recent activity timeline -- Quick action: "New Request" button (prominent CTA) - -**New Request Page:** -- Multi-step form (stepper UI): - 1. Select License Type (Resort License for POC) - 2. Fill Application Details (metadata form) - 3. Upload Required Documents (drag-drop file upload with preview) - 4. Review & Submit -- Each step validates before proceeding -- Document upload shows file name, type, size, upload progress, and hash after upload - -**Request Detail Page (`/requests/:id`):** -- Full request info card -- **Document section**: List of uploaded documents with version history, download links, and on-chain hash display -- **Approval Timeline**: Vertical timeline component showing each department's approval status, remarks, timestamps, and blockchain tx hash (clickable, links to block explorer or shows tx details modal) -- **Status Badge**: Large, prominent status indicator (DRAFT, SUBMITTED, IN_REVIEW, APPROVED, REJECTED, etc.) with appropriate colors and icons -- Action buttons: Upload New Document, Withdraw Request (if applicable) - -#### C. **Department Portal** - -**Dashboard:** -- Pending requests count (large, prominent number with pulse animation if > 0) -- **Department Wallet Section** (NEW — CRITICAL): - - Display department's Ethereum wallet address (truncated with copy button) - - Wallet balance (ETH or native token) - - Recent transactions list (approval txs sent from this wallet) - - Visual wallet card with gradient background (like a crypto wallet card) -- Queue of pending requests in a data table with sorting, filtering, search -- Recent approvals/rejections list - -**Request Review Page:** -- Split layout: Documents on the left (with viewer/preview), approval form on the right -- Document viewer: PDF preview, image preview inline -- Action buttons: Approve, Reject, Request Changes — each with remarks textarea -- Show which documents this department is required to review (highlighted) -- Show other departments' approval statuses for this request -- Confirmation dialog before submitting approval (shows tx will be recorded on-chain) -- Should give fully a crypto wallet experience to the department portal. - -#### D. **Admin Portal** - -**Dashboard:** -- System-wide stats: Total Requests, Active Workflows, Registered Departments, Blockchain Stats (block height, total txs, node count) -- Stat cards with sparkline mini-charts or progress indicators -- Recent system activity feed - -**Department Management:** -- CRUD table for departments -- Each department row shows: Name, Code, Wallet Address, API Key status, Webhook status, Active/Inactive toggle -- Department detail page with wallet info, approval history, performance metrics - -**Workflow Builder Page (`/admin/workflows/builder`):** -- **Visual drag-and-drop workflow builder** using a library like `ngx-graph`, `elkjs`, or a custom implementation with Angular CDK drag-drop -- Canvas where you can: - - Add stages (nodes) - - Connect stages with arrows (edges) showing sequential or parallel flow - - Configure each stage: name, execution type (SEQUENTIAL/PARALLEL), departments assigned, required documents, completion criteria (ALL/ANY/THRESHOLD), rejection behavior, timeout -- Stage nodes should be visually distinct (colored by status type, department icons) -- Toolbar: Add Stage, Delete, Connect, Zoom, Pan, Auto-layout -- Right sidebar: Stage configuration panel (appears when a stage is selected) -- Preview mode: Shows how a request would flow through the workflow -- Save workflow as JSON, load existing workflows - -**Audit Logs:** -- Searchable, filterable table of all system actions -- Filter by entity type, action, actor, date range -- Each row expandable to show old/new values diff - -**Blockchain Explorer (Mini):** -- Recent blocks list -- Recent transactions list with type badges (MINT_NFT, APPROVAL, DOC_UPDATE, etc) -- Transaction detail view: from, to, block number, gas used, status, related entity link - -#### E. **Shared Components** - -- **Blockchain Transaction Badge**: Shows tx hash (truncated), status (confirmed/pending/failed), clickable to show details -- **Status Badge Component**: Reusable, colored badges for all status types -- **Wallet Card Component**: Reusable wallet display with address, balance, copy, QR -- **Document Hash Display**: Shows hash with copy button, verification icon -- **Approval Timeline Component**: Vertical timeline with department avatars, status, remarks -- **Notification Toast System**: For webhook events, approval updates, errors -- **Loading Skeletons**: Shimmer loading states for all data-heavy pages -- **Empty States**: Illustrated empty states (no requests yet, no pending items, etc.) - -### 4. Wallet Section — Detailed Requirements - -This is a key differentiator for the demo. Every department has a custodial Ethereum wallet. - -**Department Wallet Dashboard Widget:** -``` -┌─────────────────────────────────────────────┐ -│ 🔷 Fire Department Wallet │ -│ │ -│ ┌─────────────────────────────────────┐ │ -│ │ Address: 0x1234...abcd 📋 │ │ -│ │ Balance: 0.045 ETH │ │ -│ │ Transactions: 23 │ │ -│ │ Last Active: 2 hours ago │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ Recent Transactions │ -│ ├─ ✅ Approval TX 0xabc... 2h ago │ -│ ├─ ✅ Approval TX 0xdef... 1d ago │ -│ └─ ❌ Rejected TX 0x789... 3d ago │ -│ │ -│ [View All Transactions] [View on Explorer]│ -└─────────────────────────────────────────────┘ -``` - -**Admin Wallet Overview:** -- Table of all department wallets with balances -- Total platform wallet stats -- Fund department wallets action (for gas) - -### 5. Technical Requirements - -- **State Management**: Use NgRx or Angular Signals for complex state (requests list, workflow builder state, wallet data). Use simple services with BehaviorSubjects for simpler state. -- **API Integration**: All API calls should go through proper Angular services with: - - HTTP interceptors for auth headers - - Global error handling interceptor - - Loading state management - - Retry logic for blockchain tx endpoints (which may take a few seconds) - - Proper TypeScript interfaces for ALL API request/response types -- **Routing**: Lazy-loaded feature modules for each portal (applicant, department, admin) -- **Guards**: Auth guards, role-based route guards -- **Real-time**: If WebSocket is available, use it for live status updates. Otherwise, implement polling for pending request updates on department dashboard. -- **Responsive**: CSS Grid + Tailwind responsive utilities. Sidebar collapses on mobile. -- **Accessibility**: ARIA labels, keyboard navigation, proper contrast ratios -- **Performance**: Virtual scrolling for long lists, lazy loading images, OnPush change detection where possible - -### 6. File/Folder Structure Target - -``` -src/ -├── app/ -│ ├── core/ -│ │ ├── interceptors/ # Auth, error, loading interceptors -│ │ ├── guards/ # Auth, role guards -│ │ ├── services/ # Auth, blockchain, wallet services -│ │ ├── models/ # TypeScript interfaces for all entities -│ │ └── constants/ # API URLs, status enums, colors -│ ├── shared/ -│ │ ├── components/ # Reusable components (status-badge, wallet-card, tx-badge, etc.) -│ │ ├── pipes/ # Truncate address, format date, etc. -│ │ ├── directives/ # Click-outside, tooltip, etc. -│ │ └── layouts/ # Shell layout with sidebar + topbar -│ ├── features/ -│ │ ├── auth/ # Login pages -│ │ ├── applicant/ # Applicant portal (dashboard, requests, documents) -│ │ ├── department/ # Department portal (dashboard, review, wallet) -│ │ ├── admin/ # Admin portal (dashboard, departments, workflows, explorer) -│ │ └── workflow-builder/ # Visual workflow builder module -│ ├── app.component.ts -│ ├── app.routes.ts -│ └── app.config.ts -├── assets/ -│ ├── images/ # Goa emblem, logos, illustrations -│ ├── icons/ # Custom SVG icons if needed -│ └── animations/ # Lottie files if used -├── styles/ -│ ├── tailwind.css # Tailwind imports -│ ├── _variables.scss # Color palette, spacing, typography tokens -│ ├── _glassmorphism.scss # Glass card mixins -│ ├── _animations.scss # Keyframe animations -│ └── global.scss # Global styles, resets -└── environments/ - ├── environment.ts - └── environment.prod.ts -``` - -### 7. API Endpoints Reference - -- refer the backend api docs for the api endpoints. - -**Auth Headers for Department APIs:** -``` -X-API-Key: {department_api_key} -X-Department-Code: {department_code} // e.g., "FIRE_DEPT" -``` - -### 8. Priority Order - -Execute in this order: -1. **Fix build errors** — get `ng serve` running clean -2. **Install and configure Tailwind CSS** (if not already) -3. **Create the shared layout** (dark theme shell with sidebar, topbar) -4. **Build shared components** (status badge, wallet card, tx badge, etc.) -5. **Rebuild Applicant Portal** pages -6. **Rebuild Department Portal** pages (with wallet section) -7. **Rebuild Admin Portal** pages -8. **Build Workflow Builder** (most complex, do last) -9. **Polish**: animations, loading states, empty states, error states -10. **Test all API integrations** end-to-end - -### 9. Important Notes - -- **DO NOT** change backend API contracts — the backend team is fixing those separately -- **DO** add proper error handling and fallback UI if an API is temporarily unavailable (show friendly error states, not blank pages) -- **DO** use mock/dummy data in the UI for any endpoints not yet ready, clearly mark with `// TODO: Replace with real API` comments. mock data should be realistic and should look like real data. -- **DO** make the wallet section functional even if the wallet API isn't ready — show realistic mock data with a clean UI- double check backend API docs for wallet API endpoints. -- **DO** ensure all environment-specific values (API base URL, etc.) come from environment files -- The demo scenario is: Applicant creates request → uploads docs → submits → departments review → approve/reject → NFT minted. **Every step of this flow must work and look incredible.** - -### 10. Design References & Compliance Resources - -**MANDATORY Government References (read these before designing):** -- DBIM v3.0 (Digital Brand Identity Manual): https://dbimtoolkit.digifootprint.gov.in/static/uploads/2025/10/8bc5c5028b2396be4cc07d0acba47ff7.pdf -- DBIM Toolkit (icons, templates, resources): https://dbimtoolkit.digifootprint.gov.in -- GIGW 3.0 (Guidelines for Indian Government Websites): https://guidelines.india.gov.in -- Goa State Emblem reference: https://dip.goa.gov.in/state-emblem/ - -**For Visual/UX Inspiration (adapt to DBIM constraints):** -- India's gov.in portal (reference for government header/footer pattern) -- Etherscan (block explorer aesthetics — transaction lists, hash displays) -- Aave/Uniswap dashboards (DeFi dashboard data density) -- Linear.app (clean enterprise SaaS patterns) -- Vercel dashboard (developer-focused, clean layout) - -**Key Tailwind Config Additions for DBIM Compliance:** -```typescript -// tailwind.config.js — extend with DBIM tokens -theme: { - extend: { - colors: { - 'dbim': { - 'white': '#FFFFFF', // Inclusive White - 'linen': '#EBEAEA', // Background secondary - 'brown': '#150202', // Deep Earthy Brown - text color - 'black': '#000000', // Emblem on light BG - 'deep-blue': '#1D0A69', // Gov.In identity blue - 'success': '#198754', // Liberty Green - 'warning': '#FFC107', // Mustard Yellow - 'error': '#DC3545', // Coral Red - 'info': '#0D6EFD', // Blue - info & hyperlinks - 'grey-1': '#C6C6C6', - 'grey-2': '#8E8E8E', - 'grey-3': '#606060', - } - }, - fontFamily: { - 'sans': ['Noto Sans', 'sans-serif'], // DBIM mandatory font - } - } -} -``` - -The goal: When a government official sees this, they should think "this is the future of governance" — modern, trustworthy, transparent, and beautiful — **while recognizing it immediately as an official Government of India platform** because it follows DBIM branding standards they've seen on other government websites. - ---- - -**Start by scanning the existing codebase, listing all issues, then fix and rebuild systematically following the priority order above. Ask me if you need clarification on any specific API contract or business logic.** \ No newline at end of file diff --git a/frontend-plan.md b/frontend-plan.md deleted file mode 100644 index ca64cc6..0000000 --- a/frontend-plan.md +++ /dev/null @@ -1,451 +0,0 @@ - Here is Claude's plan: -╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - Goa-GEL Full Stack Implementation Plan - - Overview - - Build a complete end-to-end working system: Angular 21 frontend, Solidity smart contracts, and full integration with the existing NestJS backend. - - Current State Summary - - - Backend: 11 modules, 50+ endpoints, fully implemented - - Frontend: Angular 21 skeleton - models exist, no components/services/routes - - Blockchain: 4 Besu nodes running, backend services coded, NO contracts deployed - - Docker: 8 containers operational (api, postgres, redis, minio, 4 besu nodes) - - --- - Phase 1: Core Infrastructure (Frontend) - - 1.1 Core Services - - Create the foundation services that all features depend on. - ┌────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/services/storage.service.ts │ LocalStorage wrapper for tokens/user data │ - ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/services/api.service.ts │ HttpClient wrapper, handles {success,data,timestamp} response format │ - ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/services/auth.service.ts │ Login/logout, token management, current user state │ - ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/services/notification.service.ts │ MatSnackBar wrapper for toast notifications │ - ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/services/index.ts │ Barrel export │ - └────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘ - Key Implementation Details: - - ApiService: Generic methods get(), post(), put(), patch(), delete() that unwrap the {success, data} envelope - - AuthService: Exposes currentUser$ BehaviorSubject, isAuthenticated$, userRole$ observables - - Use environment.ts for apiBaseUrl and storage keys - - 1.2 HTTP Interceptors - ┌─────────────────────────────────────────────────────────┬───────────────────────────────────────────────┐ - │ File │ Purpose │ - ├─────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/core/interceptors/auth.interceptor.ts │ Adds Authorization: Bearer header │ - ├─────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/core/interceptors/error.interceptor.ts │ Global error handling, 401 -> logout redirect │ - ├─────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/core/interceptors/index.ts │ Barrel export │ - └─────────────────────────────────────────────────────────┴───────────────────────────────────────────────┘ - 1.3 Route Guards - ┌────────────────────────────────────────────┬───────────────────────────────────────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/guards/auth.guard.ts │ Blocks unauthenticated access, redirects to /login │ - ├────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/guards/role.guard.ts │ Blocks access based on user role (data.roles route param) │ - ├────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤ - │ frontend/src/app/core/guards/index.ts │ Barrel export │ - └────────────────────────────────────────────┴───────────────────────────────────────────────────────────┘ - 1.4 Layouts - ┌─────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────┐ - │ File │ Purpose │ - ├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤ - │ frontend/src/app/layouts/main-layout/main-layout.component.ts │ Sidenav + toolbar shell for authenticated pages │ - ├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤ - │ frontend/src/app/layouts/main-layout/main-layout.component.html │ Template with mat-sidenav-container │ - ├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤ - │ frontend/src/app/layouts/main-layout/main-layout.component.scss │ Layout styles │ - ├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤ - │ frontend/src/app/layouts/auth-layout/auth-layout.component.ts │ Minimal centered layout for login │ - ├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤ - │ frontend/src/app/layouts/auth-layout/auth-layout.component.html │ Template │ - ├─────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────┤ - │ frontend/src/app/layouts/auth-layout/auth-layout.component.scss │ Styles │ - └─────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────┘ - 1.5 App Configuration - ┌────────────────────────────────┬───────────────────────────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/app.config.ts │ Update to add provideHttpClient, interceptors │ - ├────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/app.routes.ts │ Root routes with lazy loading │ - ├────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/app.ts │ Update root component │ - ├────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/app.html │ Minimal template (just router-outlet) │ - ├────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/styles.scss │ Global Material theme + base styles │ - └────────────────────────────────┴───────────────────────────────────────────────┘ - Verification: ng serve runs without errors, hitting / redirects to /login - - --- - Phase 2: Authentication Feature - - 2.1 Auth Components - ┌─────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────┐ - │ File │ Purpose │ - ├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤ - │ frontend/src/app/features/auth/auth.routes.ts │ Auth feature routes │ - ├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤ - │ frontend/src/app/features/auth/login-select/login-select.component.ts │ Choose login type (Department/DigiLocker) │ - ├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤ - │ frontend/src/app/features/auth/department-login/department-login.component.ts │ Department login form (apiKey, departmentCode) │ - ├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤ - │ frontend/src/app/features/auth/department-login/department-login.component.html │ Form template │ - ├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤ - │ frontend/src/app/features/auth/digilocker-login/digilocker-login.component.ts │ DigiLocker login (digilockerId, name, email, phone) │ - ├─────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤ - │ frontend/src/app/features/auth/digilocker-login/digilocker-login.component.html │ Form template │ - └─────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────┘ - Login Flow: - 1. /login -> LoginSelectComponent (two buttons: Department Login, DigiLocker Login) - 2. Department: POST /auth/department/login with {apiKey, departmentCode} - 3. DigiLocker: POST /auth/digilocker/login with {digilockerId, name?, email?, phone?} - 4. On success: Store token, redirect to /dashboard - - Test Credentials (from seed): - - Department: FIRE_SAFETY / fire_safety_api_key_12345 - - Department: BUILDING_DEPT / building_dept_api_key_12345 - - DigiLocker: Any digilockerId (auto-creates user) - - Verification: Can login as department and applicant, token stored, redirects to dashboard - - --- - Phase 3: Dashboard & Requests - - 3.1 Shared Components - ┌─────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────┐ - │ File │ Purpose │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ - │ frontend/src/app/shared/components/page-header/page-header.component.ts │ Reusable page title + actions bar │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ - │ frontend/src/app/shared/components/status-badge/status-badge.component.ts │ Colored badge for request/approval status │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ - │ frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts │ MatDialog confirmation modal │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ - │ frontend/src/app/shared/components/loading-spinner/loading-spinner.component.ts │ Centered spinner │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ - │ frontend/src/app/shared/components/empty-state/empty-state.component.ts │ "No data" placeholder │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ - │ frontend/src/app/shared/components/index.ts │ Barrel export │ - └─────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────┘ - 3.2 Dashboard Feature - ┌────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤ - │ frontend/src/app/features/dashboard/dashboard.routes.ts │ Dashboard routes │ - ├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤ - │ frontend/src/app/features/dashboard/dashboard.component.ts │ Role-based dashboard switcher │ - ├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤ - │ frontend/src/app/features/dashboard/admin-dashboard/admin-dashboard.component.ts │ Admin stats (calls GET /admin/stats) │ - ├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤ - │ frontend/src/app/features/dashboard/department-dashboard/department-dashboard.component.ts │ Pending approvals list │ - ├────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────┤ - │ frontend/src/app/features/dashboard/applicant-dashboard/applicant-dashboard.component.ts │ My requests list │ - └────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────┘ - Dashboard Content by Role: - - ADMIN: Platform stats cards (total requests, approvals, documents, departments), system health, recent activity - - DEPARTMENT: Pending requests needing approval, recent approvals made - - APPLICANT: My requests with status, quick action to create new request - - 3.3 Requests Feature - ┌─────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────┐ - │ File │ Purpose │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/requests.routes.ts │ Request feature routes │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/request-list/request-list.component.ts │ Paginated list with filters │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/request-list/request-list.component.html │ MatTable with pagination │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/request-detail/request-detail.component.ts │ Full request view + timeline │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/request-detail/request-detail.component.html │ Tabs: Details, Documents, Approvals, Timeline │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/request-create/request-create.component.ts │ Create new request form │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/request-create/request-create.component.html │ Stepper form │ - ├─────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤ - │ frontend/src/app/features/requests/services/request.service.ts │ Request API methods │ - └─────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────┘ - Request Workflow: - 1. Create (DRAFT) -> Upload documents -> Submit (SUBMITTED) - 2. Department reviews -> Approve/Reject/Request Changes - 3. If approved by all stages -> License minted as NFT - - Verification: Can create request, view list, view details, submit request - - --- - Phase 4: Documents & Approvals - - 4.1 Documents Feature - ┌──────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────┐ - │ File │ Purpose │ - ├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤ - │ frontend/src/app/features/documents/documents.routes.ts │ Document routes (nested under requests) │ - ├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤ - │ frontend/src/app/features/documents/document-upload/document-upload.component.ts │ File upload with drag-drop │ - ├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤ - │ frontend/src/app/features/documents/document-list/document-list.component.ts │ Documents table with download/delete │ - ├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤ - │ frontend/src/app/features/documents/document-viewer/document-viewer.component.ts │ Preview modal (PDF/images) │ - ├──────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┤ - │ frontend/src/app/features/documents/services/document.service.ts │ Document API methods │ - └──────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────┘ - Document Types: FIRE_SAFETY_CERTIFICATE, BUILDING_PLAN, PROPERTY_OWNERSHIP, INSPECTION_REPORT, etc. - - 4.2 Approvals Feature - ┌────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤ - │ frontend/src/app/features/approvals/approvals.routes.ts │ Approval routes │ - ├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤ - │ frontend/src/app/features/approvals/pending-list/pending-list.component.ts │ Pending approvals for department │ - ├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤ - │ frontend/src/app/features/approvals/approval-action/approval-action.component.ts │ Approve/Reject/Request Changes dialog │ - ├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤ - │ frontend/src/app/features/approvals/approval-history/approval-history.component.ts │ Approval trail for a request │ - ├────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────┤ - │ frontend/src/app/features/approvals/services/approval.service.ts │ Approval API methods │ - └────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────┘ - Approval Actions: - - Approve: Remarks (min 10 chars), select reviewed documents - - Reject: Remarks, rejection reason (enum) - - Request Changes: Remarks, list required documents - - Verification: Department can approve/reject requests, applicant sees updated status - - --- - Phase 5: Admin Features - - 5.1 Departments Management - ┌────────────────────────────────────────────────────────────────────────────────────────┬────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤ - │ frontend/src/app/features/departments/departments.routes.ts │ Department routes │ - ├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤ - │ frontend/src/app/features/departments/department-list/department-list.component.ts │ Departments table │ - ├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤ - │ frontend/src/app/features/departments/department-form/department-form.component.ts │ Create/edit form │ - ├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤ - │ frontend/src/app/features/departments/department-detail/department-detail.component.ts │ Stats + actions │ - ├────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────┤ - │ frontend/src/app/features/departments/services/department.service.ts │ Department API methods │ - └────────────────────────────────────────────────────────────────────────────────────────┴────────────────────────┘ - 5.2 Workflows Management - ┌────────────────────────────────────────────────────────────────────────────────────┬────────────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤ - │ frontend/src/app/features/workflows/workflows.routes.ts │ Workflow routes │ - ├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤ - │ frontend/src/app/features/workflows/workflow-list/workflow-list.component.ts │ Workflows table │ - ├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤ - │ frontend/src/app/features/workflows/workflow-form/workflow-form.component.ts │ Create/edit with stage builder │ - ├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤ - │ frontend/src/app/features/workflows/workflow-preview/workflow-preview.component.ts │ Visual workflow preview │ - ├────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────┤ - │ frontend/src/app/features/workflows/services/workflow.service.ts │ Workflow API methods │ - └────────────────────────────────────────────────────────────────────────────────────┴────────────────────────────────┘ - 5.3 Webhooks Management - ┌───────────────────────────────────────────────────────────────────────────┬─────────────────────┐ - │ File │ Purpose │ - ├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤ - │ frontend/src/app/features/webhooks/webhooks.routes.ts │ Webhook routes │ - ├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤ - │ frontend/src/app/features/webhooks/webhook-list/webhook-list.component.ts │ Webhooks table │ - ├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤ - │ frontend/src/app/features/webhooks/webhook-form/webhook-form.component.ts │ Register/edit form │ - ├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤ - │ frontend/src/app/features/webhooks/webhook-logs/webhook-logs.component.ts │ Delivery logs │ - ├───────────────────────────────────────────────────────────────────────────┼─────────────────────┤ - │ frontend/src/app/features/webhooks/services/webhook.service.ts │ Webhook API methods │ - └───────────────────────────────────────────────────────────────────────────┴─────────────────────┘ - 5.4 Audit Logs - ┌────────────────────────────────────────────────────────────────────────┬──────────────────────────────┐ - │ File │ Purpose │ - ├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤ - │ frontend/src/app/features/audit/audit.routes.ts │ Audit routes │ - ├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤ - │ frontend/src/app/features/audit/audit-list/audit-list.component.ts │ Filterable audit log table │ - ├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤ - │ frontend/src/app/features/audit/entity-trail/entity-trail.component.ts │ Timeline for specific entity │ - ├────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤ - │ frontend/src/app/features/audit/services/audit.service.ts │ Audit API methods │ - └────────────────────────────────────────────────────────────────────────┴──────────────────────────────┘ - Verification: Admin can manage departments, workflows, webhooks, view audit logs - - --- - Phase 6: Smart Contracts - - 6.1 Hardhat Project Setup - ┌──────────────────────────────┬──────────────────────────────────┐ - │ File │ Purpose │ - ├──────────────────────────────┼──────────────────────────────────┤ - │ blockchain/package.json │ Hardhat dependencies │ - ├──────────────────────────────┼──────────────────────────────────┤ - │ blockchain/hardhat.config.ts │ Besu network config (chain 1337) │ - ├──────────────────────────────┼──────────────────────────────────┤ - │ blockchain/tsconfig.json │ TypeScript config │ - ├──────────────────────────────┼──────────────────────────────────┤ - │ blockchain/.env │ Private key for deployment │ - └──────────────────────────────┴──────────────────────────────────┘ - 6.2 Solidity Contracts - ┌───────────────────────────────────────────┬─────────────────────────────────────┐ - │ File │ Purpose │ - ├───────────────────────────────────────────┼─────────────────────────────────────┤ - │ blockchain/contracts/LicenseNFT.sol │ ERC721 license tokens │ - ├───────────────────────────────────────────┼─────────────────────────────────────┤ - │ blockchain/contracts/ApprovalManager.sol │ Approval recording │ - ├───────────────────────────────────────────┼─────────────────────────────────────┤ - │ blockchain/contracts/DocumentChain.sol │ Document hash verification │ - ├───────────────────────────────────────────┼─────────────────────────────────────┤ - │ blockchain/contracts/WorkflowRegistry.sol │ Workflow registration (placeholder) │ - └───────────────────────────────────────────┴─────────────────────────────────────┘ - LicenseNFT.sol Functions: - function mint(address to, string calldata requestId, string calldata metadataUri) public returns (uint256) - function tokenOfRequest(string calldata requestId) public view returns (uint256) - function exists(uint256 tokenId) public view returns (bool) - function ownerOf(uint256 tokenId) public view returns (address) - function revoke(uint256 tokenId) public - function isRevoked(uint256 tokenId) public view returns (bool) - function getMetadata(uint256 tokenId) public view returns (string memory) - - ApprovalManager.sol Functions: - function recordApproval(string calldata requestId, address departmentAddress, uint8 status, string calldata remarksHash, string[] calldata documentHashes) public returns (bytes32) - function getRequestApprovals(string calldata requestId) public view returns (Approval[] memory) - function invalidateApproval(bytes32 approvalId) public - function verifyApproval(bytes32 approvalId, string calldata remarksHash) public view returns (bool) - function getApprovalDetails(bytes32 approvalId) public view returns (Approval memory) - - DocumentChain.sol Functions: - function recordDocumentHash(string calldata requestId, string calldata documentId, string calldata hash, uint256 version) public returns (bytes32) - function verifyDocumentHash(string calldata documentId, string calldata hash) public view returns (bool) - function getDocumentHistory(string calldata documentId) public view returns (DocumentRecord[] memory) - function getLatestDocumentHash(string calldata documentId) public view returns (string memory) - - 6.3 Deployment - ┌──────────────────────────────────┬─────────────────────────────────────────────┐ - │ File │ Purpose │ - ├──────────────────────────────────┼─────────────────────────────────────────────┤ - │ blockchain/scripts/deploy.ts │ Deploy all contracts, output addresses │ - ├──────────────────────────────────┼─────────────────────────────────────────────┤ - │ blockchain/scripts/update-env.ts │ Update backend/.env with deployed addresses │ - └──────────────────────────────────┴─────────────────────────────────────────────┘ - Verification: Contracts deployed, addresses in .env, backend connects successfully - - --- - Phase 7: Integration & Polish - - 7.1 Frontend-Blockchain Integration - ┌───────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────┐ - │ File │ Purpose │ - ├───────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────┤ - │ frontend/src/app/features/requests/request-detail/blockchain-info.component.ts │ Show NFT token ID, tx hash │ - ├───────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────┤ - │ frontend/src/app/features/documents/document-list/verification-badge.component.ts │ Show blockchain verification status │ - └───────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────┘ - 7.2 Final Polish - ┌───────────────────┬───────────────────────────────────────────────┐ - │ Task │ Description │ - ├───────────────────┼───────────────────────────────────────────────┤ - │ Error handling │ Consistent error messages across all features │ - ├───────────────────┼───────────────────────────────────────────────┤ - │ Loading states │ Spinners on all async operations │ - ├───────────────────┼───────────────────────────────────────────────┤ - │ Responsive design │ Mobile-friendly sidenav collapse │ - ├───────────────────┼───────────────────────────────────────────────┤ - │ Form validation │ Client-side validation matching backend DTOs │ - ├───────────────────┼───────────────────────────────────────────────┤ - │ Empty states │ Meaningful messages when no data │ - └───────────────────┴───────────────────────────────────────────────┘ - --- - File Count Summary - ┌──────────────────────────────┬───────────┬────────────────┐ - │ Phase │ New Files │ Modified Files │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Phase 1: Core │ 16 │ 4 │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Phase 2: Auth │ 8 │ 0 │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Phase 3: Dashboard/Requests │ 18 │ 0 │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Phase 4: Documents/Approvals │ 12 │ 0 │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Phase 5: Admin │ 20 │ 0 │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Phase 6: Contracts │ 10 │ 1 │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Phase 7: Integration │ 4 │ 2 │ - ├──────────────────────────────┼───────────┼────────────────┤ - │ Total │ ~88 │ ~7 │ - └──────────────────────────────┴───────────┴────────────────┘ - --- - Verification Checklist - - After Phase 1-2 - - - ng serve runs without errors - - Can navigate to /login - - Department login works with test credentials - - DigiLocker login works with any ID - - Token stored in localStorage - - Authenticated routes protected - - After Phase 3-4 - - - Dashboard shows role-appropriate content - - Can create new license request - - Can upload documents to request - - Can submit request for approval - - Department can see pending approvals - - Department can approve/reject requests - - After Phase 5 - - - Admin can manage departments - - Admin can manage workflows - - Admin can view audit logs - - Webhooks can be configured and tested - - After Phase 6 - - - Contracts deployed to Besu network - - Backend connects to contracts - - License minting works on approval - - End-to-End Flow - - - Applicant creates request -> uploads docs -> submits - - Department reviews -> approves - - License NFT minted on blockchain - - Applicant sees token ID and tx hash - - Document hashes verified on chain - - --- - Critical Files to Modify - - Backend (update .env after contract deployment): - - backend/.env - Update CONTRACT_ADDRESS_* variables - - Frontend (main configuration): - - frontend/src/app/app.config.ts - Add HTTP providers - - frontend/src/app/app.routes.ts - Define all routes - - frontend/src/styles.scss - Material theme - - --- - Implementation Order - - Phase 1 (Core) -> Phase 2 (Auth) -> Phase 3 (Dashboard/Requests) - -> Phase 4 (Docs/Approvals) -> Phase 5 (Admin) - -> Phase 6 (Contracts) -> Phase 7 (Integration) \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8ae6ec2..e33a72b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,3 +1,9 @@ +# ============================================================================== +# Goa GEL Frontend - Multi-stage Docker Build +# ============================================================================== +# Supports runtime configuration via environment variables +# ============================================================================== + # Stage 1: Build Angular application FROM node:20-alpine AS builder @@ -21,9 +27,13 @@ FROM nginx:alpine # Copy custom nginx configuration COPY nginx.conf /etc/nginx/nginx.conf -# Copy built application from builder stage (from browser subdirectory) +# Copy built application from builder stage COPY --from=builder /app/dist/goa-gel-frontend/browser /usr/share/nginx/html +# Copy runtime config script +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + # Expose port 80 EXPOSE 80 @@ -31,5 +41,6 @@ EXPOSE 80 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 -# Start nginx +# Use entrypoint to inject runtime config +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100644 index 0000000..c278884 --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# ============================================================================== +# Goa GEL Frontend - Docker Entrypoint +# ============================================================================== +# Injects runtime configuration from environment variables +# ============================================================================== + +set -e + +# Configuration directory in nginx html root +CONFIG_DIR="/usr/share/nginx/html/assets" +CONFIG_FILE="${CONFIG_DIR}/config.json" + +# Default values (same as build-time defaults) +API_BASE_URL="${API_BASE_URL:-http://localhost:3001/api/v1}" + +echo "=== Goa GEL Frontend Runtime Configuration ===" +echo "API_BASE_URL: ${API_BASE_URL}" +echo "==============================================" + +# Ensure config directory exists +mkdir -p "${CONFIG_DIR}" + +# Generate runtime configuration JSON +cat > "${CONFIG_FILE}" << EOF +{ + "apiBaseUrl": "${API_BASE_URL}" +} +EOF + +echo "Runtime config written to ${CONFIG_FILE}" + +# Execute the main command (nginx) +exec "$@" diff --git a/frontend/e2e/CLAUDE.md b/frontend/e2e/CLAUDE.md new file mode 100644 index 0000000..59ab83f --- /dev/null +++ b/frontend/e2e/CLAUDE.md @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index c9f55f5..115a8f7 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -62,7 +62,7 @@ test.describe('Authentication', () => { await page.goto('/auth/login'); // Fill credentials - await page.getByLabel('Email').fill('citizen@example.com'); + await page.getByLabel('Email').fill('rajesh.naik@example.com'); await page.getByLabel('Password').fill('Citizen@123'); // Submit diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index 945bd86..9f5d713 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test'; // Helper functions async function loginAsCitizen(page: Page) { await page.goto('/auth/login'); - await page.getByLabel('Email').fill('citizen@example.com'); + await page.getByLabel('Email').fill('rajesh.naik@example.com'); await page.getByLabel('Password').fill('Citizen@123'); await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL('**/dashboard**', { timeout: 10000 }); diff --git a/frontend/e2e/requests.spec.ts b/frontend/e2e/requests.spec.ts index 6e6d2e1..78616d3 100644 --- a/frontend/e2e/requests.spec.ts +++ b/frontend/e2e/requests.spec.ts @@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test'; // Helper function to login as citizen async function loginAsCitizen(page: Page) { await page.goto('/auth/login'); - await page.getByLabel('Email').fill('citizen@example.com'); + await page.getByLabel('Email').fill('rajesh.naik@example.com'); await page.getByLabel('Password').fill('Citizen@123'); await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL('**/dashboard**', { timeout: 10000 }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ccde14..7b2b798 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@angular/material": "^21.1.3", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", + "pdfjs-dist": "^4.0.379", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -2242,6 +2243,176 @@ "node": ">=8" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.25.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", @@ -4669,6 +4840,28 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4755,6 +4948,13 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4883,6 +5083,17 @@ "dev": true, "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -5260,6 +5471,16 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5297,6 +5518,13 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -5495,6 +5723,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/console.table": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/console.table/-/console.table-0.10.0.tgz", @@ -5713,7 +5948,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5772,6 +6007,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5786,7 +6028,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "optional": true, "engines": { @@ -5926,7 +6167,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5937,7 +6177,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6591,6 +6830,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6616,6 +6862,90 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6800,6 +7130,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7017,11 +7354,23 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/ini": { @@ -7873,6 +8222,32 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-fetch-happen": { "version": "15.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", @@ -8182,6 +8557,19 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -8196,7 +8584,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/msgpackr": { @@ -8255,6 +8643,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8306,7 +8701,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -8327,21 +8722,21 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause" }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -8550,6 +8945,20 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -8567,7 +8976,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8624,7 +9033,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8832,6 +9241,16 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8887,6 +9306,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/path2d-polyfill": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", + "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -8894,6 +9323,73 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "4.0.379", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.0.379.tgz", + "integrity": "sha512-6H0Gv1nna+wmrr3CakaKlZ4rbrL8hvGIFAgg4YcoFuGC0HC4B2DVjXEGTFjJEjLlf8nYi3C3/MYRcM5bNx0elA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d-polyfill": "^2.0.1" + } + }, + "node_modules/pdfjs-dist/node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pdfjs-dist/node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pdfjs-dist/node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pdfjs-dist/node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9336,7 +9832,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9454,6 +9950,58 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rolldown": { "version": "1.0.0-beta.58", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.58.tgz", @@ -9595,7 +10143,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -9616,7 +10164,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sass": { @@ -9687,7 +10235,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9743,6 +10291,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9900,6 +10455,27 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -10089,7 +10665,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -10702,7 +11278,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/validate-npm-package-license": { @@ -11001,6 +11577,71 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -11091,7 +11732,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/ws": { diff --git a/frontend/package.json b/frontend/package.json index a342850..6569987 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "@angular/material": "^21.1.3", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", + "pdfjs-dist": "^4.0.379", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/frontend/src/app/api/models/approval.models.ts b/frontend/src/app/api/models/approval.models.ts index fae9799..b9e29bd 100644 --- a/frontend/src/app/api/models/approval.models.ts +++ b/frontend/src/app/api/models/approval.models.ts @@ -18,6 +18,7 @@ export interface ApprovalResponseDto { requestId: string; departmentId: string; departmentName: string; + departmentCode?: string; status: ApprovalStatus; approvedBy?: string; remarks?: string; diff --git a/frontend/src/app/api/models/document.models.ts b/frontend/src/app/api/models/document.models.ts index 9041a13..a413e2d 100644 --- a/frontend/src/app/api/models/document.models.ts +++ b/frontend/src/app/api/models/document.models.ts @@ -4,16 +4,19 @@ */ export type DocumentType = - | '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' - | 'OTHER'; + | 'NOC' + | 'LICENSE_COPY' + | 'OTHER' + | 'FIRE_SAFETY' + | 'HEALTH_CERT' + | 'TAX_CLEARANCE' + | 'SITE_PLAN' + | 'BUILDING_PERMIT' + | 'BUSINESS_LICENSE'; export interface UploadDocumentDto { docType: DocumentType; diff --git a/frontend/src/app/api/models/request.models.ts b/frontend/src/app/api/models/request.models.ts index 2adde41..6286f2c 100644 --- a/frontend/src/app/api/models/request.models.ts +++ b/frontend/src/app/api/models/request.models.ts @@ -9,10 +9,13 @@ export type RequestType = 'NEW_LICENSE' | 'RENEWAL' | 'AMENDMENT' | 'MODIFICATIO export type RequestStatus = 'DRAFT' | 'SUBMITTED' | 'IN_REVIEW' | 'PENDING_RESUBMISSION' | 'APPROVED' | 'REJECTED' | 'REVOKED' | 'CANCELLED'; export interface CreateRequestDto { - applicantId: string; + applicantName: string; + applicantPhone?: string; + businessName?: string; requestType: RequestType; - workflowId: string; - metadata: Record; + workflowId?: string; + workflowCode?: string; + metadata?: Record; tokenId?: number; } diff --git a/frontend/src/app/api/models/workflow.models.ts b/frontend/src/app/api/models/workflow.models.ts index aa3500f..d47e51c 100644 --- a/frontend/src/app/api/models/workflow.models.ts +++ b/frontend/src/app/api/models/workflow.models.ts @@ -3,25 +3,40 @@ * Models for workflow configuration and management */ +export type ExecutionType = 'SEQUENTIAL' | 'PARALLEL'; +export type CompletionCriteria = 'ALL' | 'ANY' | 'THRESHOLD'; +export type RejectionHandling = 'FAIL_REQUEST' | 'RETRY_STAGE' | 'ESCALATE'; + +export interface DepartmentApproval { + departmentCode: string; + departmentName: string; + canDelegate: boolean; + timeoutDays?: number; +} + export interface WorkflowStage { - id: string; - name: string; - description?: string; - departmentId: string; - order: number; - isRequired: boolean; + stageId: string; + stageName: string; + stageOrder: number; + executionType: ExecutionType; + requiredApprovals: DepartmentApproval[]; + completionCriteria: CompletionCriteria; + threshold?: number; + timeoutDays?: number; + rejectionHandling: RejectionHandling; + escalationDepartment?: string; metadata?: Record; } export interface CreateWorkflowDto { name: string; - description?: string; workflowType: string; - departmentId: string; + description?: string; stages: WorkflowStage[]; onSuccessActions?: string[]; onFailureActions?: string[]; metadata?: Record; + createdBy?: string; } export interface UpdateWorkflowDto { @@ -35,11 +50,17 @@ export interface UpdateWorkflowDto { export interface WorkflowResponseDto { id: string; name: string; - description?: string; workflowType: string; - stages: WorkflowStage[]; + description?: string; + definition: { + stages: WorkflowStage[]; + onSuccessActions?: string[]; + onFailureActions?: string[]; + }; isActive: boolean; + version: number; metadata?: Record; + createdBy?: string; createdAt: string; updatedAt: string; } @@ -54,12 +75,12 @@ export interface WorkflowPreviewDto { } export interface WorkflowStagePreviewDto { - id: string; - name: string; + stageId: string; + stageName: string; description?: string; departmentCode: string; departmentName: string; - order: number; + stageOrder: number; isRequired: boolean; } diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index bdad41b..218ffe8 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,14 +1,21 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { ApplicationConfig, APP_INITIALIZER, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { routes } from './app.routes'; import { authInterceptor, errorInterceptor } from './core/interceptors'; +import { RuntimeConfigService, initializeApp } from './core/services/runtime-config.service'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), + { + provide: APP_INITIALIZER, + useFactory: initializeApp, + deps: [RuntimeConfigService], + multi: true, + }, provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])), provideAnimationsAsync(), diff --git a/frontend/src/app/core/services/api.service.ts b/frontend/src/app/core/services/api.service.ts index c6772df..6174e1b 100644 --- a/frontend/src/app/core/services/api.service.ts +++ b/frontend/src/app/core/services/api.service.ts @@ -19,7 +19,7 @@ import { shareReplay, of, } from 'rxjs'; -import { environment } from '../../../environments/environment'; +import { RuntimeConfigService } from './runtime-config.service'; // Configuration constants const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds @@ -110,6 +110,12 @@ function extractData(response: ApiResponse | null | undefined): T { return response as unknown as T; } + // Handle paginated responses: have 'data' and pagination fields but no 'success' + // These should be returned as-is, not unwrapped + if ('data' in response && !('success' in response) && ('total' in response || 'page' in response)) { + return response as unknown as T; + } + if (response.data === undefined) { // Return null as T if data is explicitly undefined but response exists return null as T; @@ -132,7 +138,14 @@ function isRetryableError(error: HttpErrorResponse): boolean { }) export class ApiService { private readonly http = inject(HttpClient); - private readonly baseUrl = environment.apiBaseUrl; + private readonly configService = inject(RuntimeConfigService); + + /** + * Get API base URL from runtime config (supports deployment-time configuration) + */ + private get baseUrl(): string { + return this.configService.apiBaseUrl; + } /** * Cache for GET requests that should be shared @@ -331,7 +344,14 @@ export class ApiService { } case HttpEventType.Response: { - const responseData = event.body?.data; + // Handle both wrapped ({data: ...}) and unwrapped responses + const body = event.body; + let responseData: T | undefined; + if (body && typeof body === 'object' && 'data' in body) { + responseData = (body as ApiResponse).data; + } else { + responseData = body as T | undefined; + } return { progress: 100, loaded: 1, diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index f00606e..8eebc1d 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -295,6 +295,7 @@ export class AuthService implements OnDestroy { name: InputSanitizer.sanitizeName(response.department.name || ''), email: InputSanitizer.sanitizeEmail(response.department.contactEmail || '') || '', departmentCode: InputSanitizer.sanitizeAlphanumeric(response.department.code || '', '_'), + departmentId: String(response.department.id), }; this.storage.setUser(user); diff --git a/frontend/src/app/core/services/runtime-config.service.ts b/frontend/src/app/core/services/runtime-config.service.ts new file mode 100644 index 0000000..aa7e939 --- /dev/null +++ b/frontend/src/app/core/services/runtime-config.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; + +export interface RuntimeConfig { + apiBaseUrl: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class RuntimeConfigService { + private config: RuntimeConfig | null = null; + + /** + * Loads runtime configuration from assets/config.json + * This allows environment-specific config without rebuilding + */ + async loadConfig(): Promise { + try { + const response = await fetch('/assets/config.json'); + if (response.ok) { + const config = await response.json(); + this.config = config; + } + } catch { + // Config file not found or invalid - use environment defaults + console.warn('Runtime config not found, using build-time defaults'); + } + } + + /** + * Get API base URL - prefers runtime config over build-time environment + */ + get apiBaseUrl(): string { + return this.config?.apiBaseUrl || environment.apiBaseUrl; + } +} + +/** + * Factory function for APP_INITIALIZER + */ +export function initializeApp(configService: RuntimeConfigService): () => Promise { + return () => configService.loadConfig(); +} diff --git a/frontend/src/app/core/services/storage.service.ts b/frontend/src/app/core/services/storage.service.ts index 3d9a5d6..beb9e44 100644 --- a/frontend/src/app/core/services/storage.service.ts +++ b/frontend/src/app/core/services/storage.service.ts @@ -36,8 +36,8 @@ export class StorageService { return null; } - // Check if token is expired - if (token && TokenValidator.isExpired(token)) { + // Check if token is expired (with 60 second buffer for clock skew) + if (token && TokenValidator.isExpired(token, 60)) { console.warn('Token expired, clearing...'); this.removeToken(); return null; @@ -61,11 +61,9 @@ export class StorageService { return; } - // Check if token is already expired - if (TokenValidator.isExpired(token)) { - console.error('Cannot store expired token'); - return; - } + // Note: We don't check expiration here because the token just came from the server. + // Clock skew between client and server could cause valid tokens to appear expired. + // Expiration is checked when retrieving the token instead. this.tokenStorage.setItem(environment.tokenStorageKey, token); } diff --git a/frontend/src/app/core/utils/token-validator.ts b/frontend/src/app/core/utils/token-validator.ts index 67ca7a7..e87cc4b 100644 --- a/frontend/src/app/core/utils/token-validator.ts +++ b/frontend/src/app/core/utils/token-validator.ts @@ -112,6 +112,7 @@ export class TokenValidator { /** * Validate token is well-formed and not expired + * Uses 120 second buffer for clock skew tolerance */ static validate(token: string | null | undefined): { valid: boolean; @@ -131,7 +132,8 @@ export class TokenValidator { return { valid: false, error: 'Failed to decode token' }; } - if (this.isExpired(token)) { + // Use 120 second buffer for clock skew between client and server + if (this.isExpired(token, 120)) { return { valid: false, error: 'Token has expired', payload }; } diff --git a/frontend/src/app/features/admin/admin-stats/admin-stats.component.ts b/frontend/src/app/features/admin/admin-stats/admin-stats.component.ts index 6534320..c9277d2 100644 --- a/frontend/src/app/features/admin/admin-stats/admin-stats.component.ts +++ b/frontend/src/app/features/admin/admin-stats/admin-stats.component.ts @@ -117,24 +117,24 @@ interface PlatformStats { } &.primary { - background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%); - color: white; + background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%) !important; + color: white !important; } &.success { - background: linear-gradient(135deg, #059669 0%, #10b981 100%); - color: white; + background: linear-gradient(135deg, #059669 0%, #10b981 100%) !important; + color: white !important; } &.info { - background: linear-gradient(135deg, var(--dbim-info) 0%, #60a5fa 100%); - color: white; + background: linear-gradient(135deg, #0d6efd 0%, #60a5fa 100%) !important; + color: white !important; } &.warning { - background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%); - color: white; + background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%) !important; + color: white !important; } &.secondary { - background: linear-gradient(135deg, var(--crypto-purple) 0%, var(--crypto-indigo) 100%); - color: white; + background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%) !important; + color: white !important; } } @@ -147,12 +147,13 @@ interface PlatformStats { align-items: center; justify-content: center; backdrop-filter: blur(10px); - } - .stat-icon { - font-size: 28px; - width: 28px; - height: 28px; + .stat-icon { + font-size: 28px !important; + width: 28px !important; + height: 28px !important; + color: white !important; + } } .stat-content { @@ -166,12 +167,13 @@ interface PlatformStats { font-weight: 700; margin-bottom: 4px; letter-spacing: -0.02em; + color: white !important; } .stat-label { font-size: 13px; - opacity: 0.9; font-weight: 500; + color: rgba(255, 255, 255, 0.9) !important; } .loading-container { @@ -183,10 +185,11 @@ interface PlatformStats { gap: 16px; p { - color: var(--dbim-grey-2); + color: #6b7280; font-size: 14px; } } + `, ], }) diff --git a/frontend/src/app/features/admin/admin.component.ts b/frontend/src/app/features/admin/admin.component.ts index 788f181..3c6c5f7 100644 --- a/frontend/src/app/features/admin/admin.component.ts +++ b/frontend/src/app/features/admin/admin.component.ts @@ -143,34 +143,13 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components'; } .admin-header { - background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%); + background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%); color: white; padding: 32px; - box-shadow: var(--shadow-elevated); + box-shadow: 0 4px 20px rgba(29, 10, 105, 0.15); position: relative; overflow: hidden; - - &::before { - content: ''; - position: absolute; - top: -50%; - right: -20%; - width: 60%; - height: 200%; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%); - pointer-events: none; - } - - &::after { - content: ''; - position: absolute; - bottom: -50%; - left: -10%; - width: 40%; - height: 150%; - background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 50%); - pointer-events: none; - } + border-radius: 0 0 16px 16px; } .header-content { @@ -192,40 +171,81 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components'; align-items: center; justify-content: center; backdrop-filter: blur(10px); - - .header-icon { - font-size: 32px; - width: 32px; - height: 32px; - } } - .header-text { - h1 { - margin: 0; - font-size: 28px; - font-weight: 700; - letter-spacing: -0.02em; - } + .header-icon { + font-size: 32px !important; + width: 32px !important; + height: 32px !important; + color: white !important; + } - .subtitle { - margin: 4px 0 0; - opacity: 0.9; - font-size: 14px; - font-weight: 400; - } + .header-text h1 { + margin: 0; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + color: white !important; + } + + .header-text .subtitle { + margin: 4px 0 0; + color: rgba(255, 255, 255, 0.9) !important; + font-size: 14px; + font-weight: 400; } .admin-content { max-width: 1400px; margin: 0 auto; padding: 24px; + background: transparent; } .tabs-card { margin-top: 24px; - border-radius: 16px !important; - overflow: hidden; + border-radius: 0 !important; + box-shadow: none !important; + background: transparent !important; + overflow: visible; + } + + :host ::ng-deep .tabs-card .mat-mdc-card { + box-shadow: none !important; + background: transparent !important; + } + + :host ::ng-deep .mat-mdc-tab-header { + background: white; + border-radius: 12px 12px 0 0; + padding: 8px 8px 0 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + } + + :host ::ng-deep .mat-mdc-tab { + min-width: 120px; + padding: 0 24px; + height: 48px; + opacity: 0.7; + } + + :host ::ng-deep .mat-mdc-tab.mdc-tab--active { + opacity: 1; + } + + :host ::ng-deep .mat-mdc-tab-labels { + gap: 4px; + } + + :host ::ng-deep .mdc-tab__text-label { + color: #1D0A69 !important; + font-weight: 500; + } + + :host ::ng-deep .mdc-tab-indicator__content--underline { + border-color: #1D0A69 !important; + border-width: 3px !important; + border-radius: 3px 3px 0 0; } .tab-icon { @@ -233,11 +253,14 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components'; font-size: 20px; width: 20px; height: 20px; + color: #1D0A69; } .tab-content { padding: 24px; - background: var(--dbim-white); + background: white; + border-radius: 0 0 12px 12px; + min-height: 400px; } .section-divider { @@ -248,10 +271,6 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components'; display: grid; grid-template-columns: 1fr 400px; gap: 24px; - - @media (max-width: 1200px) { - grid-template-columns: 1fr; - } } .dashboard-main { @@ -261,25 +280,6 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components'; .dashboard-sidebar { min-width: 0; } - - :host ::ng-deep { - .mat-mdc-tab-label { - min-width: 120px; - } - - .mat-mdc-tab-header { - background: var(--dbim-linen); - border-bottom: 1px solid rgba(29, 10, 105, 0.08); - } - - .mat-mdc-tab:not(.mat-mdc-tab-disabled).mdc-tab--active .mdc-tab__text-label { - color: var(--dbim-blue-dark); - } - - .mat-mdc-tab-body-wrapper { - background: var(--dbim-white); - } - } `, ], }) diff --git a/frontend/src/app/features/admin/transaction-dashboard/transaction-dashboard.component.ts b/frontend/src/app/features/admin/transaction-dashboard/transaction-dashboard.component.ts index c567d56..c4f54a8 100644 --- a/frontend/src/app/features/admin/transaction-dashboard/transaction-dashboard.component.ts +++ b/frontend/src/app/features/admin/transaction-dashboard/transaction-dashboard.component.ts @@ -13,6 +13,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDialogModule, MatDialog } from '@angular/material/dialog'; import { ApiService } from '../../../core/services/api.service'; +import { TransactionDetailDialogComponent } from '../../../shared/components/blockchain-explorer-mini/transaction-detail-dialog.component'; interface BlockchainTransaction { id: string; @@ -295,19 +296,19 @@ interface PaginatedResponse { align-items: center; gap: 16px; padding: 20px; - border-radius: 8px; + border-radius: 12px; color: white; - &.confirmed { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); } - &.pending { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); } - &.failed { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); } - &.total { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } + &.confirmed { background: linear-gradient(135deg, #059669 0%, #10b981 100%); } + &.pending { background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%); } + &.failed { background: linear-gradient(135deg, #DC3545 0%, #e74c3c 100%); } + &.total { background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%); } mat-icon { font-size: 40px; width: 40px; height: 40px; - opacity: 0.9; + color: white; } } @@ -318,11 +319,12 @@ interface PaginatedResponse { .stat-value { font-size: 2rem; font-weight: 600; + color: white; } .stat-label { font-size: 0.875rem; - opacity: 0.9; + color: rgba(255, 255, 255, 0.9); } .loading-container { @@ -491,7 +493,29 @@ export class TransactionDashboardComponent implements OnInit { } viewTransactionDetails(tx: BlockchainTransaction): void { - alert(`Transaction Details:\n\n${JSON.stringify(tx, null, 2)}`); + // Convert to BlockchainTransactionDto format for the dialog + const dialogData = { + id: tx.id, + txHash: tx.transactionHash || (tx as any).txHash, + type: (tx as any).txType || 'TRANSACTION', + status: tx.status, + gasUsed: tx.gasUsed ? parseInt(tx.gasUsed, 10) : undefined, + blockNumber: tx.blockNumber, + timestamp: tx.createdAt, + data: { + from: tx.from || (tx as any).fromAddress, + to: tx.to || (tx as any).toAddress, + value: tx.value, + requestId: tx.requestId || (tx as any).relatedEntityId, + }, + }; + + this.dialog.open(TransactionDetailDialogComponent, { + data: dialogData, + width: '600px', + maxHeight: '90vh', + panelClass: 'blockchain-detail-dialog', + }); } getStatusColor(status: string): string { diff --git a/frontend/src/app/features/approvals/approval-action/approval-action.component.ts b/frontend/src/app/features/approvals/approval-action/approval-action.component.ts index c4b11fb..130aeff 100644 --- a/frontend/src/app/features/approvals/approval-action/approval-action.component.ts +++ b/frontend/src/app/features/approvals/approval-action/approval-action.component.ts @@ -149,14 +149,19 @@ export class ApprovalActionComponent { ]; readonly documentTypes: { value: DocumentType; label: string }[] = [ - { value: 'FIRE_SAFETY_CERTIFICATE', label: 'Fire Safety Certificate' }, - { value: 'BUILDING_PLAN', label: 'Building Plan' }, - { value: 'PROPERTY_OWNERSHIP', label: 'Property Ownership' }, - { value: 'INSPECTION_REPORT', label: 'Inspection Report' }, - { value: 'POLLUTION_CERTIFICATE', label: 'Pollution Certificate' }, - { value: 'ELECTRICAL_SAFETY_CERTIFICATE', label: 'Electrical Safety' }, - { value: 'IDENTITY_PROOF', label: 'Identity Proof' }, + { value: 'ID_PROOF', label: 'Identity Proof' }, { value: 'ADDRESS_PROOF', label: 'Address Proof' }, + { value: 'FIRE_SAFETY', label: 'Fire Safety Certificate' }, + { value: 'FLOOR_PLAN', label: 'Floor Plan' }, + { value: 'SITE_PLAN', label: 'Site Plan' }, + { value: 'BUILDING_PERMIT', label: 'Building Permit' }, + { value: 'BUSINESS_LICENSE', label: 'Business License' }, + { value: 'PHOTOGRAPH', label: 'Photograph' }, + { value: 'NOC', label: 'No Objection Certificate' }, + { value: 'LICENSE_COPY', label: 'License Copy' }, + { value: 'HEALTH_CERT', label: 'Health Certificate' }, + { value: 'TAX_CLEARANCE', label: 'Tax Clearance' }, + { value: 'OTHER', label: 'Other Document' }, ]; readonly form = this.fb.nonNullable.group({ @@ -266,7 +271,9 @@ export class ApprovalActionComponent { }, error: (err) => { this.submitting.set(false); - this.notification.error(err?.error?.message || 'Failed to process action. Please try again.'); + // Handle different error formats + const errorMessage = err?.error?.message || err?.message || 'Failed to process action. Please try again.'; + this.notification.error(errorMessage); }, }); } diff --git a/frontend/src/app/features/approvals/approval-history/approval-history.component.ts b/frontend/src/app/features/approvals/approval-history/approval-history.component.ts index 62e957f..d064422 100644 --- a/frontend/src/app/features/approvals/approval-history/approval-history.component.ts +++ b/frontend/src/app/features/approvals/approval-history/approval-history.component.ts @@ -191,7 +191,7 @@ export class ApprovalHistoryComponent implements OnInit { private loadHistory(): void { this.approvalService.getApprovalHistory(this.requestId).subscribe({ next: (data) => { - this.approvals.set(data); + this.approvals.set(data ?? []); this.loading.set(false); }, error: () => { diff --git a/frontend/src/app/features/approvals/approval-workflow-timeline/approval-workflow-timeline.component.ts b/frontend/src/app/features/approvals/approval-workflow-timeline/approval-workflow-timeline.component.ts new file mode 100644 index 0000000..c7cf397 --- /dev/null +++ b/frontend/src/app/features/approvals/approval-workflow-timeline/approval-workflow-timeline.component.ts @@ -0,0 +1,1867 @@ +import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, inject, signal, computed, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { Clipboard } from '@angular/cdk/clipboard'; +import { forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { ApprovalService } from '../services/approval.service'; +import { ApprovalActionComponent } from '../approval-action/approval-action.component'; +import { DepartmentService } from '../../departments/services/department.service'; +import { ApiService } from '../../../core/services/api.service'; +import { AuthService } from '../../../core/services/auth.service'; +import { ApprovalResponseDto, BlockchainTransactionDto, DepartmentResponseDto } from '../../../api/models'; + +interface WorkflowStage { + id: string; + departmentId: string; + departmentName: string; + departmentCode?: string; + status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'WAITING'; + remarks?: string; + rejectionReason?: string; + approvedBy?: string; + completedAt?: string; + createdAt: string; + requiredDocuments?: string[]; +} + +interface BlockchainRecord { + txHash: string; + type: string; + status: 'PENDING' | 'CONFIRMED' | 'FAILED'; + blockNumber?: number; + timestamp: string; + tokenId?: string; + gasUsed?: number; +} + +interface ActivityLog { + id: string; + action: string; + actor: string; + actorType: 'DEPARTMENT' | 'SYSTEM' | 'APPLICANT'; + details: string; + timestamp: string; +} + +@Component({ + selector: 'app-approval-workflow-timeline', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatIconModule, + MatButtonModule, + MatProgressSpinnerModule, + MatTooltipModule, + MatChipsModule, + MatDividerModule, + MatExpansionModule, + MatDialogModule, + ], + template: ` +
+ +
+
+ account_tree +
+
+

Approval Workflow

+

Track progress across departments

+
+
+
+ {{ getOverallStatusIcon() }} + {{ formatOverallStatus() }} +
+
+
+ + @if (loading()) { +
+ +

Loading workflow...

+
+ } @else { + +
+
+
+
+
+ {{ completedStages() }} of {{ totalStages() }} departments + {{ progressPercentage() }}% complete +
+
+ + +
+

+ timeline + Department Approvals +

+ +
+ @for (stage of stages(); track stage.departmentId; let i = $index; let isLast = $last) { +
+ + @if (!isLast) { +
+ } + + +
+ @switch (stage.status) { + @case ('APPROVED') { + check + } + @case ('REJECTED') { + close + } + @case ('CHANGES_REQUESTED') { + edit_note + } + @case ('PENDING') { + hourglass_top + } + @default { + {{ i + 1 }} + } + } +
+ + +
+
+
+
+ {{ stage.departmentName }} + @if (stage.departmentCode) { + + badge + {{ stage.departmentCode }} + + } + @if (stage.approvedBy) { + + person + {{ stage.approvedBy }} + + } +
+ + {{ formatStatus(stage.status) }} + +
+ +
+ @if (stage.completedAt) { +
+ event + {{ stage.completedAt | date:'medium' }} +
+ } @else if (stage.status === 'PENDING') { + @if (canTakeAction(stage)) { +
+ notification_important + Your action required +
+ } @else { +
+ pending_actions + Awaiting department review +
+ } + } @else if (stage.status === 'WAITING') { +
+ queue + Waiting for previous approval +
+ } +
+ + + @if (canTakeAction(stage)) { +
+
+ gavel + Take action on this request: +
+
+ + + +
+
+ } + + + @if (stage.remarks || stage.rejectionReason || stage.requiredDocuments?.length) { + + } +
+
+
+ } + + + @if (showBlockchainStage()) { +
+ +
+ @if (isBlockchainConfirmed()) { + verified + } @else if (allApprovalsComplete()) { + hourglass_top + } @else if (hasRejection()) { + block + } @else { + lock + } +
+ +
+
+
+
+ + token + License NFT Minting + + Final step after all department approvals +
+ @if (isBlockchainConfirmed()) { + + verified + Minted + + } @else if (allApprovalsComplete()) { + + pending + Minting... + + } @else if (hasRejection()) { + + block + Not Applicable + + } @else { + + lock + Awaiting Approvals + + } +
+ + @if (isBlockchainConfirmed() && blockchainRecord()) { + +
+ @if (blockchainRecord()?.tokenId) { +
+ License NFT Token ID + #{{ blockchainRecord()?.tokenId }} +
+ } + +
+
+ Transaction Hash +
+ {{ truncateHash(blockchainRecord()?.txHash || '') }} + content_copy +
+
+ + @if (blockchainRecord()?.blockNumber) { +
+ Block Number + {{ blockchainRecord()?.blockNumber | number }} +
+ } + + @if (blockchainRecord()?.gasUsed) { +
+ Gas Used + {{ blockchainRecord()?.gasUsed | number }} +
+ } + +
+ Minted On + {{ blockchainRecord()?.timestamp | date:'medium' }} +
+
+
+ +
+ shield + This license NFT is cryptographically secured on the blockchain and cannot be tampered with. +
+ } @else if (hasRejection()) { + +
+ info + NFT cannot be minted because the request was rejected by one or more departments. +
+ } @else if (allApprovalsComplete()) { + +
+
+ token +
+
+ All departments approved! + Your License NFT is being minted on the blockchain... +
+
+ } @else { + +
+ info +
+ NFT will be minted automatically after all departments approve your application. + {{ completedStages() }} of {{ totalStages() }} departments approved +
+
+ } +
+
+
+ } +
+
+ + + + + + history + Activity Log + + + {{ activityLogs().length }} events + + + +
+ @for (log of activityLogs(); track log.id) { +
+
+ {{ getActivityIcon(log.action) }} +
+
+
+ {{ formatActivityAction(log.action) }} + {{ log.timestamp | date:'short' }} +
+

{{ log.details }}

+ + {{ log.actorType === 'SYSTEM' ? 'smart_toy' : 'person' }} + {{ log.actor }} + +
+
+ } + + @if (activityLogs().length === 0) { +
+ event_busy +

No activity recorded yet

+
+ } +
+
+ } +
+ `, + styles: [` + .workflow-container { + background: white; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + } + + /* Header - Fixed contrast */ + .workflow-header { + display: flex; + align-items: center; + gap: 16px; + padding: 24px; + background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%); + color: white; + } + + .header-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: white; + } + } + + .header-content { + flex: 1; + + h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: white; + } + + .subtitle { + margin: 4px 0 0; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.85); + } + } + + .overall-status { + .status-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 24px; + font-size: 0.875rem; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &.in-progress { + background: rgba(255, 193, 7, 0.25); + color: #FFF176; + } + + &.approved { + background: rgba(76, 175, 80, 0.25); + color: #A5D6A7; + } + + &.rejected { + background: rgba(244, 67, 54, 0.25); + color: #EF9A9A; + } + + &.pending { + background: rgba(255, 255, 255, 0.2); + color: white; + } + } + } + + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px; + gap: 16px; + + p { + color: #666; + margin: 0; + } + } + + /* Progress Section */ + .progress-section { + padding: 20px 24px; + background: #F8F9FA; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + } + + .progress-bar-wrapper { + height: 10px; + background: #E0E0E0; + border-radius: 5px; + overflow: hidden; + } + + .progress-bar { + height: 100%; + background: linear-gradient(90deg, #198754 0%, #28a745 100%); + border-radius: 5px; + transition: width 0.5s ease; + } + + .progress-text { + display: flex; + justify-content: space-between; + margin-top: 10px; + font-size: 0.85rem; + color: #666; + + .percentage { + font-weight: 700; + color: #198754; + } + } + + /* Timeline Section */ + .timeline-section { + padding: 24px; + } + + .section-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 24px; + font-size: 1.1rem; + font-weight: 600; + color: #1a1a1a; + + mat-icon { + color: #2563EB; + } + } + + .workflow-timeline { + position: relative; + } + + .timeline-stage { + display: flex; + gap: 16px; + padding-bottom: 24px; + position: relative; + + &:last-child { + padding-bottom: 0; + } + + &.has-feedback { + padding-bottom: 32px; + } + } + + .connector-line { + position: absolute; + left: 19px; + top: 44px; + width: 3px; + height: calc(100% - 20px); + background: #E0E0E0; + border-radius: 2px; + + &.active { + background: linear-gradient(180deg, #198754 0%, #28a745 100%); + } + } + + .stage-marker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-weight: 600; + font-size: 0.875rem; + z-index: 1; + transition: all 0.3s ease; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: white; + } + + .stage-number { + color: white; + } + + &.waiting { + background: #F5F5F5; + border: 2px dashed #BDBDBD; + + .stage-number { + color: #9E9E9E; + } + } + + &.pending { + background: linear-gradient(135deg, #FF9800 0%, #FFB74D 100%); + box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3); + animation: pulse 2s infinite; + } + + &.approved { + background: linear-gradient(135deg, #198754 0%, #28a745 100%); + box-shadow: 0 4px 12px rgba(25, 135, 84, 0.3); + } + + &.rejected { + background: linear-gradient(135deg, #DC3545 0%, #E57373 100%); + box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3); + } + + &.changes_requested { + background: linear-gradient(135deg, #2196F3 0%, #64B5F6 100%); + box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3); + } + + &.blockchain { + width: 48px; + height: 48px; + background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); + box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3); + + &.confirmed { + background: linear-gradient(135deg, #10B981 0%, #34D399 100%); + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3); + } + + &.pending { + animation: pulse 2s infinite; + } + } + } + + @keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } + } + + .stage-content { + flex: 1; + } + + .stage-card { + background: white; + border-radius: 12px; + padding: 16px; + border: 1px solid #E5E7EB; + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } + } + + .stage-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + .dept-info { + display: flex; + flex-direction: column; + gap: 4px; + } + + .department-name { + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; + display: flex; + align-items: center; + gap: 8px; + + .nft-icon { + color: #7C3AED; + } + } + + .department-code { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: #6B7280; + background: rgba(107, 114, 128, 0.1); + padding: 2px 8px; + border-radius: 4px; + font-family: 'Monaco', 'Consolas', monospace; + + mat-icon { + font-size: 12px; + width: 12px; + height: 12px; + } + } + + .reviewer-name { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + color: #2563EB; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + + mat-chip { + font-size: 0.7rem !important; + min-height: 24px !important; + padding: 0 10px !important; + font-weight: 600 !important; + + &.status-approved, &.status-confirmed { + background: rgba(25, 135, 84, 0.15) !important; + color: #157347 !important; + } + + &.status-pending { + background: rgba(255, 152, 0, 0.15) !important; + color: #E65100 !important; + } + + &.status-rejected { + background: rgba(220, 53, 69, 0.15) !important; + color: #b02a37 !important; + } + + &.status-changes_requested { + background: rgba(33, 150, 243, 0.15) !important; + color: #0d47a1 !important; + } + + &.status-waiting { + background: rgba(158, 158, 158, 0.15) !important; + color: #616161 !important; + } + + mat-icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + margin-right: 4px; + } + } + + .stage-meta-row { + margin-top: 10px; + } + + .stage-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: #666; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #888; + } + + &.pending-notice { + color: #E65100; + + mat-icon { + color: #E65100; + } + } + } + + .action-required-notice { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + font-weight: 600; + color: #1D0A69; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%); + padding: 8px 12px; + border-radius: 8px; + margin-top: 10px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #6366F1; + } + } + + .action-buttons-section { + margin-top: 16px; + padding: 16px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.05) 100%); + border-radius: 12px; + border: 1px solid rgba(99, 102, 241, 0.2); + } + + .action-prompt { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 0.9rem; + font-weight: 600; + color: #1D0A69; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #6366F1; + } + } + + .action-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + + button { + mat-icon { + margin-right: 4px; + } + } + + &.waiting-notice { + color: #757575; + + mat-icon { + color: #757575; + } + } + } + + /* Department Feedback Section - PROMINENT */ + .feedback-section { + margin-top: 16px; + padding: 16px; + border-radius: 10px; + border-left: 4px solid; + + &.feedback-approved { + background: linear-gradient(135deg, rgba(25, 135, 84, 0.08) 0%, rgba(40, 167, 69, 0.05) 100%); + border-left-color: #198754; + } + + &.feedback-rejected { + background: linear-gradient(135deg, rgba(220, 53, 69, 0.08) 0%, rgba(229, 115, 115, 0.05) 100%); + border-left-color: #DC3545; + } + + &.feedback-changes_requested { + background: linear-gradient(135deg, rgba(33, 150, 243, 0.08) 0%, rgba(100, 181, 246, 0.05) 100%); + border-left-color: #2196F3; + } + + &.feedback-pending { + background: linear-gradient(135deg, rgba(255, 152, 0, 0.08) 0%, rgba(255, 183, 77, 0.05) 100%); + border-left-color: #FF9800; + } + } + + .feedback-header { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 12px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + .feedback-approved & { + color: #157347; + } + + .feedback-rejected & { + color: #b02a37; + } + + .feedback-changes_requested & { + color: #0d47a1; + } + + .feedback-pending & { + color: #E65100; + } + } + + .feedback-reason { + display: flex; + gap: 8px; + margin-bottom: 10px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.7); + border-radius: 6px; + + .reason-label { + font-weight: 600; + color: #333; + font-size: 0.85rem; + } + + .reason-value { + color: #DC3545; + font-size: 0.85rem; + font-weight: 500; + } + } + + .feedback-remarks { + padding: 12px 14px; + background: rgba(255, 255, 255, 0.7); + border-radius: 6px; + + p { + margin: 0; + font-size: 0.9rem; + color: #333; + line-height: 1.6; + font-style: italic; + + &::before { + content: '"'; + font-size: 1.2rem; + color: #888; + } + + &::after { + content: '"'; + font-size: 1.2rem; + color: #888; + } + } + } + + .required-docs { + margin-top: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.7); + border-radius: 6px; + + .docs-label { + font-weight: 600; + color: #333; + font-size: 0.85rem; + display: block; + margin-bottom: 8px; + } + + ul { + margin: 0; + padding-left: 20px; + + li { + color: #0d47a1; + font-size: 0.85rem; + margin-bottom: 4px; + + &:last-child { + margin-bottom: 0; + } + } + } + } + + /* Blockchain Section */ + .blockchain-stage { + margin-top: 16px; + padding-top: 24px; + border-top: 2px dashed rgba(124, 58, 237, 0.3); + } + + .blockchain-content { + .stage-card { + border-color: rgba(124, 58, 237, 0.3); + } + } + + .blockchain-card { + background: linear-gradient(135deg, rgba(124, 58, 237, 0.03) 0%, rgba(167, 139, 250, 0.03) 100%); + } + + .blockchain-title { + color: #5B21B6 !important; + } + + .blockchain-details { + margin-top: 16px; + } + + .blockchain-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-top: 16px; + } + + .blockchain-field { + &.nft-field { + text-align: center; + padding: 20px; + background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(167, 139, 250, 0.1) 100%); + border-radius: 12px; + margin-bottom: 8px; + + .token-id { + font-size: 2.5rem !important; + font-weight: 700; + color: #7C3AED; + display: block; + margin-top: 4px; + } + } + } + + .field-label { + display: block; + font-size: 0.7rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + font-weight: 500; + } + + .field-value { + font-size: 0.9rem; + font-weight: 600; + color: #1a1a1a; + } + + .hash-container { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #F5F5F5; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #EEEEEE; + + .copy-icon { + opacity: 1; + color: #2563EB; + } + } + } + + .tx-hash { + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.8rem; + color: #1D0A69; + } + + .copy-icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + opacity: 0.5; + transition: all 0.2s; + color: #666; + } + + .blockchain-verified-banner { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding: 14px 16px; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(52, 211, 153, 0.1) 100%); + border-radius: 10px; + border: 1px solid rgba(16, 185, 129, 0.3); + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: #059669; + } + + span { + font-size: 0.85rem; + color: #047857; + line-height: 1.4; + font-weight: 500; + } + } + + .blockchain-subtitle { + font-size: 0.75rem; + color: #888; + font-weight: 400; + } + + .rejected-card { + background: linear-gradient(135deg, rgba(220, 53, 69, 0.03) 0%, rgba(229, 115, 115, 0.03) 100%) !important; + border-color: rgba(220, 53, 69, 0.2) !important; + } + + .blockchain-waiting { + display: flex; + align-items: flex-start; + gap: 12px; + margin-top: 16px; + padding: 16px; + background: linear-gradient(135deg, rgba(124, 58, 237, 0.05) 0%, rgba(167, 139, 250, 0.05) 100%); + border-radius: 10px; + border: 1px dashed rgba(124, 58, 237, 0.3); + + mat-icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #7C3AED; + margin-top: 2px; + } + + .waiting-text { + display: flex; + flex-direction: column; + gap: 6px; + + span { + font-size: 0.85rem; + color: #555; + line-height: 1.4; + } + + .approval-progress { + font-size: 0.8rem; + color: #7C3AED; + font-weight: 600; + } + } + } + + .blockchain-rejected { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding: 16px; + background: rgba(220, 53, 69, 0.08); + border-radius: 10px; + border: 1px solid rgba(220, 53, 69, 0.2); + + mat-icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #DC3545; + } + + span { + font-size: 0.85rem; + color: #b02a37; + line-height: 1.4; + } + } + + .blockchain-minting { + display: flex; + align-items: center; + gap: 16px; + margin-top: 16px; + padding: 20px; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(52, 211, 153, 0.08) 100%); + border-radius: 10px; + border: 1px solid rgba(16, 185, 129, 0.3); + + .minting-animation { + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, #10B981 0%, #34D399 100%); + display: flex; + align-items: center; + justify-content: center; + animation: mintPulse 1.5s ease-in-out infinite; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: white; + } + } + + .minting-text { + display: flex; + flex-direction: column; + gap: 4px; + + strong { + font-size: 0.95rem; + color: #059669; + } + + span { + font-size: 0.85rem; + color: #047857; + } + } + } + + @keyframes mintPulse { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); + } + 50% { + transform: scale(1.05); + box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); + } + } + + .stage-marker.blockchain.waiting { + background: linear-gradient(135deg, #9CA3AF 0%, #D1D5DB 100%); + box-shadow: 0 4px 12px rgba(156, 163, 175, 0.3); + + mat-icon { + color: white; + } + } + + /* Activity Panel */ + .activity-panel { + margin: 24px; + border-radius: 12px !important; + box-shadow: none !important; + border: 1px solid #E5E7EB !important; + background: #FAFAFA !important; + + ::ng-deep .mat-expansion-panel-header-title { + display: flex; + align-items: center; + gap: 8px; + color: #333; + + mat-icon { + color: #666; + } + } + + ::ng-deep .mat-expansion-panel-header-description { + color: #888; + } + } + + .activity-list { + max-height: 300px; + overflow-y: auto; + } + + .activity-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid #EEEEEE; + + &:last-child { + border-bottom: none; + } + } + + .activity-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &.approve { + background: rgba(25, 135, 84, 0.15); + color: #157347; + } + + &.reject { + background: rgba(220, 53, 69, 0.15); + color: #b02a37; + } + + &.submit { + background: rgba(33, 150, 243, 0.15); + color: #0d47a1; + } + + &.create, &.update { + background: rgba(124, 58, 237, 0.15); + color: #6D28D9; + } + + &.default { + background: rgba(158, 158, 158, 0.15); + color: #616161; + } + } + + .activity-content { + flex: 1; + } + + .activity-header { + display: flex; + justify-content: space-between; + align-items: center; + } + + .activity-action { + font-size: 0.875rem; + font-weight: 600; + color: #1a1a1a; + } + + .activity-time { + font-size: 0.75rem; + color: #888; + } + + .activity-details { + margin: 4px 0; + font-size: 0.8rem; + color: #555; + line-height: 1.4; + } + + .activity-actor { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: #888; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + + .empty-activity { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px; + color: #888; + + mat-icon { + font-size: 32px; + width: 32px; + height: 32px; + margin-bottom: 8px; + opacity: 0.5; + } + + p { + margin: 0; + } + } + `], +}) +export class ApprovalWorkflowTimelineComponent implements OnInit, OnDestroy { + @Input({ required: true }) requestId!: string; + @Input() txHash?: string; + @Input() tokenId?: string; + @Output() approvalActionTaken = new EventEmitter(); + + private readonly approvalService = inject(ApprovalService); + private readonly departmentService = inject(DepartmentService); + private readonly api = inject(ApiService); + private readonly authService = inject(AuthService); + private readonly clipboard = inject(Clipboard); + private readonly dialog = inject(MatDialog); + private readonly destroyRef = inject(DestroyRef); + + // Cache for department details to avoid repeated API calls + private readonly departmentCache = new Map(); + + // Store the raw approvals for action dialogs + private rawApprovals: ApprovalResponseDto[] = []; + + readonly loading = signal(true); + readonly stages = signal([]); + readonly blockchainRecord = signal(null); + readonly activityLogs = signal([]); + + readonly totalStages = computed(() => this.stages().length); + readonly completedStages = computed(() => + this.stages().filter(s => s.status === 'APPROVED').length + ); + readonly progressPercentage = computed(() => { + const total = this.totalStages(); + if (total === 0) return 0; + return Math.round((this.completedStages() / total) * 100); + }); + + readonly overallStatus = computed(() => { + const stageList = this.stages(); + if (stageList.some(s => s.status === 'REJECTED')) return 'rejected'; + if (stageList.every(s => s.status === 'APPROVED')) { + return this.isBlockchainConfirmed() ? 'approved' : 'in-progress'; + } + if (stageList.some(s => s.status === 'PENDING' || s.status === 'CHANGES_REQUESTED')) { + return 'in-progress'; + } + return 'pending'; + }); + + ngOnInit(): void { + this.loadWorkflowData(); + } + + ngOnDestroy(): void { + // Cleanup handled by DestroyRef + } + + private loadWorkflowData(): void { + this.loading.set(true); + + // Load approvals for the request + this.approvalService.getApprovalHistory(this.requestId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (approvals) => { + this.processApprovals(approvals); + this.loadDepartmentDetails(approvals); + this.loadBlockchainData(); + this.generateActivityLogs(approvals); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + }, + }); + } + + /** + * Load department details for all approvals to get actual names and codes + */ + private loadDepartmentDetails(approvals: ApprovalResponseDto[]): void { + // Get unique department IDs + const uniqueDeptIds = [...new Set(approvals.map(a => a.departmentId))]; + + // Filter out IDs we already have cached + const idsToFetch = uniqueDeptIds.filter(id => !this.departmentCache.has(id)); + + if (idsToFetch.length === 0) { + // All cached, just update stages + this.updateStagesWithDepartmentDetails(); + return; + } + + // Fetch department details for each unique ID + const requests = idsToFetch.map(id => + this.departmentService.getDepartment(id).pipe( + catchError(() => of(null)) // Ignore errors for individual departments + ) + ); + + forkJoin(requests) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (departments) => { + // Cache the results + departments.forEach((dept, index) => { + if (dept) { + this.departmentCache.set(idsToFetch[index], dept); + } + }); + // Update stages with department details + this.updateStagesWithDepartmentDetails(); + }, + error: () => { + // Silently fail - we'll just show IDs + }, + }); + } + + /** + * Update stages signal with cached department details + */ + private updateStagesWithDepartmentDetails(): void { + const currentStages = this.stages(); + const updatedStages = currentStages.map(stage => { + const dept = this.departmentCache.get(stage.departmentId); + if (dept) { + return { + ...stage, + departmentName: dept.name || stage.departmentName, + departmentCode: dept.code || stage.departmentCode, + }; + } + return stage; + }); + this.stages.set(updatedStages); + } + + private processApprovals(approvals: ApprovalResponseDto[]): void { + // Store raw approvals for action dialogs + this.rawApprovals = approvals; + + const stageList: WorkflowStage[] = approvals.map(approval => ({ + id: approval.id, + departmentId: approval.departmentId, + departmentName: approval.departmentName || this.formatDepartmentId(approval.departmentId), + departmentCode: approval.departmentCode, + status: approval.status as WorkflowStage['status'], + remarks: approval.remarks, + rejectionReason: approval.rejectionReason, + approvedBy: approval.approvedBy, + completedAt: approval.completedAt, + createdAt: approval.createdAt, + requiredDocuments: approval.requiredDocuments, + })); + + this.stages.set(stageList); + } + + private loadBlockchainData(): void { + // Use provided inputs if available (from parent component) + if (this.txHash) { + this.blockchainRecord.set({ + txHash: this.txHash, + type: 'LICENSE_MINT', + status: 'CONFIRMED', // If we have a txHash, assume confirmed + timestamp: new Date().toISOString(), + tokenId: this.tokenId, + }); + return; + } + + // Fallback: Try to load blockchain transaction for this request via API + // This endpoint may require admin privileges + this.api.get(`/admin/blockchain/transactions`, { + requestId: this.requestId, + limit: 1 + }).pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response: any) => { + const transactions = response?.data || response || []; + if (transactions.length > 0) { + const tx = transactions[0]; + this.blockchainRecord.set({ + txHash: tx.txHash, + type: tx.type, + status: tx.status, + blockNumber: tx.blockNumber, + timestamp: tx.timestamp, + tokenId: tx.data?.tokenId, + gasUsed: tx.gasUsed, + }); + } + }, + error: () => { + // Silently fail - blockchain data is optional + }, + }); + } + + private generateActivityLogs(approvals: ApprovalResponseDto[]): void { + const logs: ActivityLog[] = []; + + // Generate logs from approvals + approvals.forEach((approval, index) => { + // Creation log + logs.push({ + id: `${approval.id}-created`, + action: 'CREATE', + actor: 'System', + actorType: 'SYSTEM', + details: `Approval request created for ${approval.departmentName}`, + timestamp: approval.createdAt, + }); + + // Status change logs + if (approval.status !== 'PENDING') { + logs.push({ + id: `${approval.id}-${approval.status}`, + action: approval.status, + actor: approval.approvedBy || approval.departmentName, + actorType: 'DEPARTMENT', + details: this.getStatusChangeDetails(approval), + timestamp: approval.completedAt || approval.updatedAt, + }); + } + }); + + // Sort by timestamp descending (newest first) + logs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + this.activityLogs.set(logs); + } + + private getStatusChangeDetails(approval: ApprovalResponseDto): string { + switch (approval.status) { + case 'APPROVED': + return `${approval.departmentName} approved the request${approval.remarks ? `: ${approval.remarks}` : ''}`; + case 'REJECTED': + return `${approval.departmentName} rejected the request: ${this.formatRejectionReason(approval.rejectionReason || '')}`; + case 'CHANGES_REQUESTED': + return `${approval.departmentName} requested changes${approval.remarks ? `: ${approval.remarks}` : ''}`; + default: + return `Status updated to ${approval.status}`; + } + } + + // Helper methods + isStageCompleted(stage: WorkflowStage): boolean { + return stage.status === 'APPROVED'; + } + + isStageCurrent(stage: WorkflowStage): boolean { + return stage.status === 'PENDING' || stage.status === 'CHANGES_REQUESTED'; + } + + showBlockchainStage(): boolean { + return this.stages().length > 0; + } + + allApprovalsComplete(): boolean { + return this.stages().length > 0 && this.stages().every(s => s.status === 'APPROVED'); + } + + hasRejection(): boolean { + return this.stages().some(s => s.status === 'REJECTED'); + } + + isBlockchainConfirmed(): boolean { + return this.blockchainRecord()?.status === 'CONFIRMED'; + } + + isBlockchainPending(): boolean { + return this.blockchainRecord()?.status === 'PENDING'; + } + + formatStatus(status: string): string { + return status.replace(/_/g, ' '); + } + + formatRejectionReason(reason: string): string { + return reason.replace(/_/g, ' ').toLowerCase().replace(/^\w/, c => c.toUpperCase()); + } + + getFeedbackIcon(status: string): string { + switch (status) { + case 'APPROVED': return 'check_circle'; + case 'REJECTED': return 'cancel'; + case 'CHANGES_REQUESTED': return 'edit_note'; + default: return 'comment'; + } + } + + getFeedbackTitle(status: string): string { + switch (status) { + case 'APPROVED': return 'Department Approval Notes'; + case 'REJECTED': return 'Rejection Details'; + case 'CHANGES_REQUESTED': return 'Changes Requested'; + default: return 'Department Feedback'; + } + } + + formatDepartmentId(deptId: string): string { + return deptId + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, c => c.toUpperCase()) + .replace(/Dept/g, 'Department'); + } + + getOverallStatusIcon(): string { + switch (this.overallStatus()) { + case 'approved': return 'verified'; + case 'rejected': return 'cancel'; + case 'in-progress': return 'pending_actions'; + default: return 'hourglass_empty'; + } + } + + formatOverallStatus(): string { + switch (this.overallStatus()) { + case 'approved': return 'Fully Approved'; + case 'rejected': return 'Rejected'; + case 'in-progress': return 'In Progress'; + default: return 'Pending'; + } + } + + truncateHash(hash: string): string { + if (!hash || hash.length <= 20) return hash || ''; + return `${hash.substring(0, 10)}...${hash.substring(hash.length - 6)}`; + } + + copyHash(hash: string): void { + this.clipboard.copy(hash); + } + + getActivityIcon(action: string): string { + switch (action) { + case 'APPROVED': return 'check_circle'; + case 'REJECTED': return 'cancel'; + case 'CHANGES_REQUESTED': return 'edit'; + case 'CREATE': return 'add_circle'; + case 'SUBMIT': return 'send'; + default: return 'info'; + } + } + + getActivityIconClass(action: string): string { + switch (action) { + case 'APPROVED': return 'approve'; + case 'REJECTED': return 'reject'; + case 'CHANGES_REQUESTED': + case 'CREATE': return 'create'; + case 'SUBMIT': return 'submit'; + default: return 'default'; + } + } + + formatActivityAction(action: string): string { + switch (action) { + case 'APPROVED': return 'Approval Granted'; + case 'REJECTED': return 'Request Rejected'; + case 'CHANGES_REQUESTED': return 'Changes Requested'; + case 'CREATE': return 'Request Created'; + case 'SUBMIT': return 'Request Submitted'; + default: return action.replace(/_/g, ' '); + } + } + + /** + * Check if the current department user can take action on a specific stage + */ + canTakeAction(stage: WorkflowStage): boolean { + // Only department users can take actions + if (!this.authService.isDepartment()) return false; + + // Can only act on PENDING stages + if (stage.status !== 'PENDING') return false; + + // Check if current user's department ID matches this stage + const currentUser = this.authService.getCurrentUser(); + if (!currentUser?.departmentId) return false; + + // Compare department IDs (case-insensitive for UUID format variations) + return currentUser.departmentId.toLowerCase() === stage.departmentId.toLowerCase(); + } + + /** + * Open approval action dialog + */ + openApprovalDialog(stage: WorkflowStage, action: 'approve' | 'reject' | 'changes'): void { + // Find the raw approval DTO for this stage + const approval = this.rawApprovals.find(a => a.id === stage.id); + if (!approval) return; + + const dialogRef = this.dialog.open(ApprovalActionComponent, { + data: { approval, action }, + width: '500px', + }); + + dialogRef.afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result) { + // Reload workflow data + this.loadWorkflowData(); + // Emit event so parent can reload if needed + this.approvalActionTaken.emit(); + } + }); + } +} diff --git a/frontend/src/app/features/approvals/pending-list/pending-list.component.ts b/frontend/src/app/features/approvals/pending-list/pending-list.component.ts index 7ba334a..968654f 100644 --- a/frontend/src/app/features/approvals/pending-list/pending-list.component.ts +++ b/frontend/src/app/features/approvals/pending-list/pending-list.component.ts @@ -37,6 +37,7 @@ import { ApprovalResponseDto } from '../../../api/models'; template: `
@@ -47,6 +48,17 @@ import { ApprovalResponseDto } from '../../../api/models';
+ } @else if (hasError()) { + + + } @else if (approvals().length === 0) { Actions - - - +
+ + + +
@@ -146,9 +160,21 @@ import { ApprovalResponseDto } from '../../../api/models'; } .mat-column-actions { - width: 300px; + width: 380px; text-align: right; } + + .action-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: nowrap; + + button { + white-space: nowrap; + } + } `, ], }) @@ -184,9 +210,10 @@ export class PendingListComponent implements OnInit, OnDestroy { this.totalItems.set(response?.total ?? 0); this.loading.set(false); }, - error: () => { + error: (err) => { this.hasError.set(true); this.loading.set(false); + this.notification.error(err?.message || 'Failed to load pending approvals'); }, }); } diff --git a/frontend/src/app/features/approvals/services/approval.service.ts b/frontend/src/app/features/approvals/services/approval.service.ts index 9315c16..778edb3 100644 --- a/frontend/src/app/features/approvals/services/approval.service.ts +++ b/frontend/src/app/features/approvals/services/approval.service.ts @@ -1,6 +1,8 @@ import { Injectable, inject } from '@angular/core'; -import { Observable, throwError, map, catchError } from 'rxjs'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, throwError, map, catchError, timeout } from 'rxjs'; import { ApiService, validateId, validatePagination } from '../../../core/services/api.service'; +import { RuntimeConfigService } from '../../../core/services/runtime-config.service'; import { ApprovalResponseDto, PaginatedApprovalsResponse, @@ -22,11 +24,33 @@ export interface RequestChangesDto { requiredDocuments: string[]; } +/** + * Backend paginated response with meta wrapper + */ +interface BackendPaginatedResponse { + data: ApprovalResponseDto[]; + meta?: { + total?: number; + page?: number; + limit?: number; + totalPages?: number; + hasNext?: boolean; + hasPrev?: boolean; + }; + // Also support flat pagination fields + total?: number; + page?: number; + limit?: number; + totalPages?: number; + hasNextPage?: boolean; +} + /** * Ensures response has valid data array for paginated approvals + * Handles both { data, meta: {...} } and { data, total, page, ... } formats */ function ensureValidPaginatedResponse( - response: PaginatedApprovalsResponse | null | undefined, + response: BackendPaginatedResponse | null | undefined, page: number, limit: number ): PaginatedApprovalsResponse { @@ -41,13 +65,21 @@ function ensureValidPaginatedResponse( }; } + // Extract pagination from meta object if present, otherwise use flat fields + const meta = response.meta; + const total = meta?.total ?? response.total ?? 0; + const responsePage = meta?.page ?? response.page ?? page; + const responseLimit = meta?.limit ?? response.limit ?? limit; + const totalPages = meta?.totalPages ?? response.totalPages ?? 0; + const hasNextPage = meta?.hasNext ?? response.hasNextPage ?? false; + return { data: Array.isArray(response.data) ? response.data : [], - total: typeof response.total === 'number' && response.total >= 0 ? response.total : 0, - page: typeof response.page === 'number' && response.page >= 1 ? response.page : page, - limit: typeof response.limit === 'number' && response.limit >= 1 ? response.limit : limit, - totalPages: typeof response.totalPages === 'number' && response.totalPages >= 0 ? response.totalPages : 0, - hasNextPage: typeof response.hasNextPage === 'boolean' ? response.hasNextPage : false, + total: typeof total === 'number' && total >= 0 ? total : 0, + page: typeof responsePage === 'number' && responsePage >= 1 ? responsePage : page, + limit: typeof responseLimit === 'number' && responseLimit >= 1 ? responseLimit : limit, + totalPages: typeof totalPages === 'number' && totalPages >= 0 ? totalPages : 0, + hasNextPage: typeof hasNextPage === 'boolean' ? hasNextPage : false, }; } @@ -102,16 +134,25 @@ function validateDocumentIds(docs: string[] | undefined | null): string[] { }) export class ApprovalService { private readonly api = inject(ApiService); + private readonly http = inject(HttpClient); + private readonly configService = inject(RuntimeConfigService); + + private get baseUrl(): string { + return this.configService.apiBaseUrl; + } getPendingApprovals(page = 1, limit = 10): Observable { const validated = validatePagination(page, limit); - return this.api - .get('/approvals/pending', { - page: validated.page, - limit: validated.limit, - }) + // Use HttpClient directly to avoid extractData unwrapping the paginated response + const params = new HttpParams() + .set('page', validated.page.toString()) + .set('limit', validated.limit.toString()); + + return this.http + .get(`${this.baseUrl}/approvals/pending`, { params }) .pipe( + timeout(30000), map((response) => ensureValidPaginatedResponse(response, validated.page, validated.limit)), catchError((error: unknown) => { const message = error instanceof Error ? error.message : 'Failed to fetch pending approvals'; @@ -124,7 +165,7 @@ export class ApprovalService { try { const validId = validateId(requestId, 'Request ID'); - return this.api.get(`/requests/${validId}/approvals`).pipe( + return this.api.get(`/approvals/requests/${validId}`).pipe( map((response) => ensureValidArray(response)), catchError((error: unknown) => { const message = @@ -178,7 +219,7 @@ export class ApprovalService { reviewedDocuments: validateDocumentIds(dto.reviewedDocuments), }; - return this.api.post(`/requests/${validId}/approve`, sanitizedDto).pipe( + return this.api.post(`/approvals/${validId}/approve`, sanitizedDto).pipe( catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to approve request: ${requestId}`; return throwError(() => new Error(message)); @@ -201,12 +242,12 @@ export class ApprovalService { return throwError(() => new Error('Rejection reason is required')); } - const sanitizedDto: RejectRequestDto = { + const sanitizedDto = { remarks: validateRemarks(dto.remarks), - rejectionReason: dto.rejectionReason, + reason: dto.rejectionReason, // Backend expects 'reason' not 'rejectionReason' }; - return this.api.post(`/requests/${validId}/reject`, sanitizedDto).pipe( + return this.api.post(`/approvals/${validId}/reject`, sanitizedDto).pipe( catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to reject request: ${requestId}`; return throwError(() => new Error(message)); @@ -236,7 +277,7 @@ export class ApprovalService { }; return this.api - .post(`/requests/${validId}/request-changes`, sanitizedDto) + .post(`/approvals/requests/${validId}/request-changes`, sanitizedDto) .pipe( catchError((error: unknown) => { const message = @@ -253,7 +294,8 @@ export class ApprovalService { try { const validId = validateId(requestId, 'Request ID'); - return this.api.get(`/requests/${validId}/approval-history`).pipe( + // Use the existing approvals-by-request endpoint which returns all approvals for a request + return this.api.get(`/approvals/requests/${validId}`).pipe( map((response) => ensureValidArray(response)), catchError((error: unknown) => { const message = diff --git a/frontend/src/app/features/audit/audit-list/audit-list.component.ts b/frontend/src/app/features/audit/audit-list/audit-list.component.ts index 440159d..d7887cb 100644 --- a/frontend/src/app/features/audit/audit-list/audit-list.component.ts +++ b/frontend/src/app/features/audit/audit-list/audit-list.component.ts @@ -101,7 +101,13 @@ import { AuditLogDto, AuditAction, ActorType } from '../../../api/models'; - + @@ -212,6 +218,16 @@ import { AuditLogDto, AuditAction, ActorType } from '../../../api/models'; font-family: monospace; } + .timestamp-value { + font-size: 0.875rem; + color: #333; + } + + .timestamp-missing { + color: #999; + font-style: italic; + } + .action-create { background-color: #c8e6c9 !important; color: #2e7d32 !important; diff --git a/frontend/src/app/features/audit/entity-trail/entity-trail.component.ts b/frontend/src/app/features/audit/entity-trail/entity-trail.component.ts index 8b75fd5..16abfe3 100644 --- a/frontend/src/app/features/audit/entity-trail/entity-trail.component.ts +++ b/frontend/src/app/features/audit/entity-trail/entity-trail.component.ts @@ -276,7 +276,7 @@ export class EntityTrailComponent implements OnInit { private loadTrail(): void { this.auditService.getEntityTrail(this.entityType(), this.entityId()).subscribe({ next: (trail) => { - this.events.set(trail.events); + this.events.set(trail?.events ?? []); this.loading.set(false); }, error: () => { diff --git a/frontend/src/app/features/auth/email-login/email-login.component.ts b/frontend/src/app/features/auth/email-login/email-login.component.ts index 725b38f..d8170cc 100644 --- a/frontend/src/app/features/auth/email-login/email-login.component.ts +++ b/frontend/src/app/features/auth/email-login/email-login.component.ts @@ -2,14 +2,12 @@ import { Component } from '@angular/core'; import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { Router, RouterModule } from '@angular/router'; import { CommonModule } from '@angular/common'; -import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatDividerModule } from '@angular/material/divider'; import { AuthService } from '../../../core/services/auth.service'; import { InputSanitizer } from '../../../core/utils/input-sanitizer'; @@ -28,291 +26,422 @@ interface DemoAccount { CommonModule, ReactiveFormsModule, RouterModule, - MatCardModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatSnackBarModule, - MatDividerModule, ], template: ` - - + + -
-

- info - Demo Accounts -

-

Click any account to auto-fill credentials

+ + -
-
- {{ account.icon }} -
- {{ account.role }} - {{ account.email }} - {{ account.description }} -
-
-
- -
- security - All demo accounts use the same password format: Role@123 + +
+
+ - - +
+ vpn_key + Password: Role@123 +
+
+
+ + + + arrow_back + Other login options +
`, styles: [ ` - .email-login-container { - min-height: 100vh; + // ============================================================================= + // EMAIL LOGIN - Dark Glass-Morphism Theme + // ============================================================================= + + .login-form { + position: relative; + } + + // ============================================================================= + // HEADER + // ============================================================================= + .form-header { + text-align: center; + margin-bottom: 32px; + position: relative; + } + + .header-glow { + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + width: 200px; + height: 100px; + background: radial-gradient(ellipse, rgba(99, 102, 241, 0.2) 0%, transparent 70%); + pointer-events: none; + } + + .form-title { + font-size: 28px; + font-weight: 700; + background: linear-gradient(135deg, #FFFFFF 0%, #c7d2fe 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 8px; + } + + .form-subtitle { + font-size: 14px; + color: rgba(255, 255, 255, 0.5); + margin: 0; + } + + // ============================================================================= + // FORM FIELDS + // ============================================================================= + .form-field { + margin-bottom: 20px; + } + + .field-label { + display: block; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 8px; + } + + .input-wrapper { + position: relative; + display: flex; + align-items: center; + } + + .input-icon { + position: absolute; + left: 14px; + color: rgba(255, 255, 255, 0.4); + font-size: 20px; + width: 20px; + height: 20px; + pointer-events: none; + } + + .form-input { + width: 100%; + padding: 14px 14px 14px 46px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + font-size: 15px; + color: rgba(255, 255, 255, 0.95); + transition: all 0.2s ease; + outline: none; + + &::placeholder { + color: rgba(255, 255, 255, 0.35); + } + + &:focus { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(99, 102, 241, 0.5); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); + } + + &:hover:not(:focus) { + border-color: rgba(255, 255, 255, 0.2); + } + } + + .toggle-password { + position: absolute; + right: 8px; + background: transparent; + border: none; + cursor: pointer; + padding: 8px; + color: rgba(255, 255, 255, 0.4); + transition: color 0.2s ease; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + &:hover { + color: rgba(255, 255, 255, 0.7); + } + } + + .field-error { + display: block; + font-size: 12px; + color: #f87171; + margin-top: 6px; + padding-left: 4px; + } + + // ============================================================================= + // SUBMIT BUTTON + // ============================================================================= + .submit-btn { + width: 100%; + padding: 14px; + background: linear-gradient(135deg, #6366F1 0%, #818cf8 100%); + border: none; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + color: white; + cursor: pointer; + transition: all 0.25s ease; display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - padding: 20px; - } - - .login-card { - width: 100%; - max-width: 600px; - } - - mat-card-header { - display: block; - text-align: center; + gap: 8px; margin-bottom: 24px; - mat-card-title { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; + &:hover:not(:disabled) { + background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%); + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(99, 102, 241, 0.3); } - .logo-icon { - font-size: 48px; - width: 48px; - height: 48px; - color: #1976d2; + &:disabled { + opacity: 0.5; + cursor: not-allowed; } - h2 { - margin: 0; - font-size: 1.75rem; - font-weight: 500; - color: #1976d2; - } - } - - .subtitle { - text-align: center; - color: rgba(0, 0, 0, 0.54); - margin: 8px 0 0; - font-size: 0.875rem; - } - - .full-width { - width: 100%; - margin-bottom: 16px; - } - - .login-button { - height: 48px; - font-size: 16px; - margin-top: 8px; - mat-spinner { - display: inline-block; - margin-right: 8px; + ::ng-deep circle { + stroke: white; + } } } - .divider { - margin: 32px 0 24px; - } - - .demo-accounts { - margin-top: 24px; - } - - .demo-title { + // ============================================================================= + // DEMO TOGGLE & PANEL + // ============================================================================= + .demo-toggle { display: flex; align-items: center; + justify-content: center; gap: 8px; - margin: 0 0 8px; - font-size: 1.125rem; + width: 100%; + padding: 12px; + background: transparent; + border: 1px dashed rgba(255, 255, 255, 0.15); + border-radius: 12px; + cursor: pointer; + font-size: 13px; font-weight: 500; - color: #1976d2; + color: rgba(255, 255, 255, 0.5); + transition: all 0.2s ease; mat-icon { - font-size: 20px; - width: 20px; - height: 20px; + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + background: rgba(255, 255, 255, 0.03); + border-color: rgba(99, 102, 241, 0.3); + color: rgba(255, 255, 255, 0.7); } } - .demo-subtitle { - color: rgba(0, 0, 0, 0.54); - font-size: 0.875rem; - margin: 0 0 16px; + .demo-panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1); + + &.expanded { + max-height: 350px; + } } - .demo-grid { + .demo-panel-content { + padding-top: 16px; display: flex; flex-direction: column; gap: 8px; } - .demo-card { + .demo-account { display: flex; align-items: center; gap: 12px; - padding: 12px; - border: 2px solid #e0e0e0; - border-radius: 8px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; cursor: pointer; transition: all 0.2s ease; + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } + &:hover { - border-color: #1976d2; - background-color: #f5f5f5; + background: rgba(255, 255, 255, 0.06); + border-color: rgba(99, 102, 241, 0.3); } &.selected { - border-color: #1976d2; - background-color: #e3f2fd; - } - - mat-icon { - font-size: 32px; - width: 32px; - height: 32px; + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.4); } } - .demo-info { + .account-info { + flex: 1; display: flex; flex-direction: column; - flex: 1; gap: 2px; - - strong { - font-size: 0.875rem; - color: #333; - } - - .demo-email { - font-size: 0.75rem; - color: #666; - font-family: monospace; - } - - .demo-description { - font-size: 0.75rem; - color: rgba(0, 0, 0, 0.54); - } } - .credentials-note { + .account-role { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + } + + .account-email { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + font-family: 'SF Mono', monospace; + } + + .password-hint { display: flex; align-items: center; gap: 8px; - margin-top: 16px; - padding: 12px; - background-color: #fff3e0; - border-radius: 8px; - font-size: 0.875rem; - color: #e65100; + padding: 10px 14px; + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: 10px; + margin-top: 4px; mat-icon { - font-size: 20px; - width: 20px; - height: 20px; + font-size: 16px; + width: 16px; + height: 16px; + color: #34d399; + } + + span { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); } code { - background-color: rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.3); padding: 2px 6px; border-radius: 4px; - font-family: monospace; + font-family: 'SF Mono', monospace; + color: #34d399; + } + } + + // ============================================================================= + // BACK LINK + // ============================================================================= + .back-link { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 24px; + color: rgba(255, 255, 255, 0.4); + text-decoration: none; + font-size: 13px; + transition: color 0.2s ease; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + color: #818cf8; } } `, @@ -322,6 +451,7 @@ export class EmailLoginComponent { loginForm: FormGroup; loading = false; hidePassword = true; + showDemo = false; selectedDemo: string | null = null; demoAccounts: DemoAccount[] = [ @@ -329,35 +459,28 @@ export class EmailLoginComponent { role: 'Admin', email: 'admin@goa.gov.in', password: 'Admin@123', - description: 'System administrator with full access', + description: 'System administrator', icon: 'admin_panel_settings', }, { - role: 'Fire Department', + role: 'Fire Dept', email: 'fire@goa.gov.in', password: 'Fire@123', - description: 'Fire safety inspection officer', + description: 'Fire safety officer', icon: 'local_fire_department', }, { role: 'Tourism', email: 'tourism@goa.gov.in', password: 'Tourism@123', - description: 'Tourism license reviewer', + description: 'Tourism reviewer', icon: 'luggage', }, - { - role: 'Municipality', - email: 'municipality@goa.gov.in', - password: 'Municipality@123', - description: 'Municipal building permit officer', - icon: 'location_city', - }, { role: 'Citizen', - email: 'citizen@example.com', + email: 'rajesh.naik@example.com', password: 'Citizen@123', - description: 'Citizen applying for licenses', + description: 'Citizen applicant', icon: 'person', }, ]; @@ -384,13 +507,12 @@ export class EmailLoginComponent { getRoleColor(role: string): string { const colors: { [key: string]: string } = { - Admin: '#d32f2f', - 'Fire Department': '#f57c00', - Tourism: '#1976d2', - Municipality: '#388e3c', - Citizen: '#7b1fa2', + Admin: '#f87171', + 'Fire Dept': '#fb923c', + Tourism: '#60a5fa', + Citizen: '#a78bfa', }; - return colors[role] || '#666'; + return colors[role] || '#818cf8'; } async onSubmit(): Promise { @@ -399,9 +521,8 @@ export class EmailLoginComponent { } this.loading = true; - // Sanitize inputs to prevent XSS/injection attacks const email = InputSanitizer.sanitizeEmail(this.loginForm.value.email || ''); - const password = this.loginForm.value.password || ''; // Don't sanitize password, just validate length + const password = this.loginForm.value.password || ''; try { await this.authService.login(email, password); @@ -410,15 +531,8 @@ export class EmailLoginComponent { panelClass: ['success-snackbar'], }); - // Navigate based on user role - const user = this.authService.currentUser(); - if (user?.role === 'ADMIN' || user?.type === 'ADMIN') { - this.router.navigate(['/admin']); - } else if (user?.role === 'DEPARTMENT' || user?.type === 'DEPARTMENT') { - this.router.navigate(['/dashboard']); - } else { - this.router.navigate(['/dashboard']); - } + // All users go to dashboard after login + this.router.navigate(['/dashboard']); } catch (error: any) { this.snackBar.open( error?.error?.message || 'Invalid email or password', diff --git a/frontend/src/app/features/auth/login-select/login-select.component.ts b/frontend/src/app/features/auth/login-select/login-select.component.ts index 25fa492..f96b7ea 100644 --- a/frontend/src/app/features/auth/login-select/login-select.component.ts +++ b/frontend/src/app/features/auth/login-select/login-select.component.ts @@ -1,67 +1,69 @@ -import { Component } from '@angular/core'; +import { Component, signal } from '@angular/core'; import { RouterModule } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatRippleModule } from '@angular/material/core'; +import { CommonModule } from '@angular/common'; + +interface DemoCredential { + role: string; + credential: string; + description: string; +} @Component({ selector: 'app-login-select', standalone: true, - imports: [RouterModule, MatButtonModule, MatIconModule, MatRippleModule], + imports: [CommonModule, RouterModule, MatButtonModule, MatIconModule, MatRippleModule], template: `
diff --git a/frontend/src/app/features/workflows/services/workflow.service.ts b/frontend/src/app/features/workflows/services/workflow.service.ts index 03f7153..c8003de 100644 --- a/frontend/src/app/features/workflows/services/workflow.service.ts +++ b/frontend/src/app/features/workflows/services/workflow.service.ts @@ -54,8 +54,8 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C throw new Error('Workflow name cannot exceed 200 characters'); } - if (!dto.departmentId || typeof dto.departmentId !== 'string' || dto.departmentId.trim().length === 0) { - throw new Error('Department ID is required'); + if (!dto.workflowType || typeof dto.workflowType !== 'string') { + throw new Error('Workflow type is required'); } if (!dto.stages || !Array.isArray(dto.stages) || dto.stages.length === 0) { @@ -63,11 +63,11 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C } // Validate each stage - dto.stages.forEach((stage, index) => { - if (!stage.name || typeof stage.name !== 'string' || stage.name.trim().length === 0) { + dto.stages.forEach((stage: any, index: number) => { + if (!stage.stageName || typeof stage.stageName !== 'string' || stage.stageName.trim().length === 0) { throw new Error(`Stage ${index + 1}: Name is required`); } - if (!stage.order || typeof stage.order !== 'number' || stage.order < 1) { + if (typeof stage.stageOrder !== 'number' || stage.stageOrder < 1) { throw new Error(`Stage ${index + 1}: Valid order is required`); } }); @@ -76,7 +76,6 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C ...dto, name: dto.name.trim(), description: dto.description?.trim() || undefined, - departmentId: dto.departmentId.trim(), }; } @@ -118,6 +117,13 @@ function validateUpdateWorkflowDto(dto: UpdateWorkflowDto | null | undefined): U sanitized.stages = dto.stages; } + if (dto.metadata !== undefined) { + if (typeof dto.metadata !== 'object' || dto.metadata === null) { + throw new Error('Metadata must be an object'); + } + sanitized.metadata = dto.metadata; + } + return sanitized; } @@ -131,7 +137,7 @@ export class WorkflowService { const validated = validatePagination(page, limit); return this.api - .get('/workflows', { + .getRaw('/workflows', { page: validated.page, limit: validated.limit, }) @@ -148,16 +154,13 @@ export class WorkflowService { try { const validId = validateId(id, 'Workflow ID'); - return this.api.get(`/workflows/${validId}`).pipe( + return this.api.getRaw(`/workflows/${validId}`).pipe( map((response) => { if (!response) { throw new Error('Workflow not found'); } - // Ensure nested arrays are valid - return { - ...response, - stages: Array.isArray(response.stages) ? response.stages : [], - }; + // Response structure has stages inside definition + return response; }), catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to fetch workflow: ${id}`; @@ -173,7 +176,7 @@ export class WorkflowService { try { const sanitizedDto = validateCreateWorkflowDto(dto); - return this.api.post('/workflows', sanitizedDto).pipe( + return this.api.postRaw('/workflows', sanitizedDto).pipe( catchError((error: unknown) => { const message = error instanceof Error ? error.message : 'Failed to create workflow'; return throwError(() => new Error(message)); diff --git a/frontend/src/app/features/workflows/workflow-builder/workflow-builder.component.ts b/frontend/src/app/features/workflows/workflow-builder/workflow-builder.component.ts index cfad770..2cd8a65 100644 --- a/frontend/src/app/features/workflows/workflow-builder/workflow-builder.component.ts +++ b/frontend/src/app/features/workflows/workflow-builder/workflow-builder.component.ts @@ -21,7 +21,7 @@ import { WorkflowService } from '../services/workflow.service'; import { DepartmentService } from '../../departments/services/department.service'; import { NotificationService } from '../../../core/services/notification.service'; import { AuthService } from '../../../core/services/auth.service'; -import { WorkflowResponseDto, WorkflowStage, DepartmentResponseDto } from '../../../api/models'; +import { WorkflowResponseDto, DepartmentResponseDto } from '../../../api/models'; // Node position interface for canvas positioning interface NodePosition { @@ -29,13 +29,20 @@ interface NodePosition { y: number; } -// Extended stage with visual properties -interface VisualStage extends WorkflowStage { +// Visual stage interface for canvas display (decoupled from backend model) +interface VisualStage { + id: string; + name: string; + description?: string; + departmentId: string; + order: number; + isRequired: boolean; position: NodePosition; isSelected: boolean; isStartNode?: boolean; isEndNode?: boolean; connections: string[]; // IDs of connected stages (outgoing) + metadata?: Record; } // Connection between stages @@ -152,7 +159,7 @@ export class WorkflowBuilderComponent implements OnInit { private loadDepartments(): void { this.departmentService.getDepartments(1, 100).subscribe({ next: (response) => { - this.departments.set(response.data); + this.departments.set(response?.data ?? []); }, }); } @@ -168,15 +175,31 @@ export class WorkflowBuilderComponent implements OnInit { isActive: workflow.isActive, }); - // Convert stages to visual stages with positions - const visualStages = workflow.stages.map((stage, index) => ({ - ...stage, - position: this.calculateStagePosition(index, workflow.stages.length), - isSelected: false, - isStartNode: index === 0, - isEndNode: index === workflow.stages.length - 1, - connections: index < workflow.stages.length - 1 ? [workflow.stages[index + 1].id] : [], - })); + // Get stages from definition (backend format) + const backendStages = workflow.definition?.stages || []; + + // Convert backend stages to visual stages with positions + const visualStages: VisualStage[] = backendStages.map((stage, index) => { + // Find department by code to get departmentId + const dept = this.departments().find(d => + d.code === stage.requiredApprovals?.[0]?.departmentCode + ); + + return { + id: stage.stageId, + name: stage.stageName, + description: stage.metadata?.['description'] || '', + departmentId: dept?.id || '', + order: stage.stageOrder, + isRequired: stage.metadata?.['isRequired'] ?? true, + position: stage.metadata?.['position'] || this.calculateStagePosition(index, backendStages.length), + isSelected: false, + isStartNode: index === 0, + isEndNode: index === backendStages.length - 1, + connections: index < backendStages.length - 1 ? [backendStages[index + 1].stageId] : [], + metadata: stage.metadata, + }; + }); this.stages.set(visualStages); this.rebuildConnections(); @@ -513,24 +536,34 @@ export class WorkflowBuilderComponent implements OnInit { const departmentId = currentUser?.departmentId || this.stages().find(s => s.departmentId)?.departmentId || ''; - const dto = { + // Transform visual stages to backend DTO format + const dto: any = { name: workflowData.name, description: workflowData.description || undefined, workflowType: workflowData.workflowType, - departmentId: departmentId, - stages: this.stages().map((s, index) => ({ - id: s.id, - name: s.name, - description: s.description, - departmentId: s.departmentId, - order: index + 1, - isRequired: s.isRequired, - metadata: { - ...s.metadata, - position: s.position, - connections: s.connections, - }, - })), + stages: this.stages().map((s, index) => { + const department = this.departments().find(d => d.id === s.departmentId); + return { + stageId: s.id, + stageName: s.name, + stageOrder: index, // Backend expects 0-indexed + executionType: s.metadata?.['executionType'] || 'SEQUENTIAL', + requiredApprovals: [{ + departmentCode: department?.code || '', + departmentName: department?.name || 'Unknown', + canDelegate: false, + }], + completionCriteria: s.metadata?.['completionCriteria'] || 'ALL', + rejectionHandling: 'FAIL_REQUEST', + metadata: { + description: s.description, + position: s.position, + connections: s.connections, + timeoutHours: s.metadata?.['timeoutHours'], + isRequired: s.isRequired, + }, + }; + }), metadata: { visualLayout: { stages: this.stages().map(s => ({ diff --git a/frontend/src/app/features/workflows/workflow-form/workflow-form.component.ts b/frontend/src/app/features/workflows/workflow-form/workflow-form.component.ts index 341eb81..94028f0 100644 --- a/frontend/src/app/features/workflows/workflow-form/workflow-form.component.ts +++ b/frontend/src/app/features/workflows/workflow-form/workflow-form.component.ts @@ -304,7 +304,7 @@ export class WorkflowFormComponent implements OnInit { private loadDepartments(): void { this.departmentService.getDepartments(1, 100).subscribe({ next: (response) => { - this.departments.set(response.data); + this.departments.set(response?.data ?? []); }, }); } @@ -322,14 +322,19 @@ export class WorkflowFormComponent implements OnInit { }); this.stagesArray.clear(); - workflow.stages.forEach((stage) => { + const stages = workflow.definition?.stages || []; + stages.forEach((stage) => { + // Find department by code to get departmentId + const dept = this.departments().find(d => + d.code === stage.requiredApprovals?.[0]?.departmentCode + ); this.stagesArray.push( this.fb.group({ - id: [stage.id], - name: [stage.name, Validators.required], - departmentId: [stage.departmentId, Validators.required], - order: [stage.order], - isRequired: [stage.isRequired], + id: [stage.stageId], + name: [stage.stageName, Validators.required], + departmentId: [dept?.id || '', Validators.required], + order: [stage.stageOrder], + isRequired: [stage.metadata?.['isRequired'] ?? true], }) ); }); @@ -414,18 +419,30 @@ export class WorkflowFormComponent implements OnInit { const departmentId = currentUser?.departmentId || (values.stages[0]?.departmentId) || ''; - const dto = { + // Transform to backend DTO format + const dto: any = { name: normalizeWhitespace(values.name), description: normalizeWhitespace(values.description) || undefined, workflowType: values.workflowType!, - departmentId: departmentId, - stages: values.stages.map((s, i) => ({ - id: s.id || `stage-${i + 1}`, - name: normalizeWhitespace(s.name) || `Stage ${i + 1}`, - departmentId: s.departmentId || '', - isRequired: s.isRequired ?? true, - order: i + 1, - })), + stages: values.stages.map((s, i) => { + const department = this.departments().find(d => d.id === s.departmentId); + return { + stageId: s.id || `stage-${i + 1}`, + stageName: normalizeWhitespace(s.name) || `Stage ${i + 1}`, + stageOrder: i, // Backend expects 0-indexed + executionType: 'SEQUENTIAL' as const, + requiredApprovals: [{ + departmentCode: department?.code || '', + departmentName: department?.name || 'Unknown', + canDelegate: false, + }], + completionCriteria: 'ALL' as const, + rejectionHandling: 'FAIL_REQUEST' as const, + metadata: { + isRequired: s.isRequired ?? true, + }, + }; + }), }; const action$ = this.isEditMode() diff --git a/frontend/src/app/features/workflows/workflow-list/workflow-list.component.ts b/frontend/src/app/features/workflows/workflow-list/workflow-list.component.ts index fcebab6..92f8df8 100644 --- a/frontend/src/app/features/workflows/workflow-list/workflow-list.component.ts +++ b/frontend/src/app/features/workflows/workflow-list/workflow-list.component.ts @@ -81,7 +81,7 @@ import { WorkflowResponseDto } from '../../../api/models'; diff --git a/frontend/src/app/features/workflows/workflow-preview/workflow-preview.component.ts b/frontend/src/app/features/workflows/workflow-preview/workflow-preview.component.ts index f80003c..74cb2f2 100644 --- a/frontend/src/app/features/workflows/workflow-preview/workflow-preview.component.ts +++ b/frontend/src/app/features/workflows/workflow-preview/workflow-preview.component.ts @@ -61,7 +61,7 @@ import { WorkflowResponseDto } from '../../../api/models';
Total Stages - {{ wf.stages.length || 0 }} + {{ wf.definition?.stages?.length || 0 }}
Created @@ -75,14 +75,14 @@ import { WorkflowResponseDto } from '../../../api/models';

Approval Stages

- @for (stage of wf.stages; track stage.id; let i = $index; let last = $last) { + @for (stage of (wf.definition?.stages || []); track stage.stageId; let i = $index; let last = $last) {
{{ i + 1 }}
-
{{ stage.name }}
-
{{ stage.departmentId }}
- @if (stage.isRequired) { +
{{ stage.stageName }}
+
{{ stage.requiredApprovals?.[0]?.departmentCode || 'N/A' }}
+ @if (stage.metadata?.['isRequired'] !== false) { Required }
diff --git a/frontend/src/app/layouts/auth-layout/auth-layout.component.html b/frontend/src/app/layouts/auth-layout/auth-layout.component.html index 16c97ef..bd616c8 100644 --- a/frontend/src/app/layouts/auth-layout/auth-layout.component.html +++ b/frontend/src/app/layouts/auth-layout/auth-layout.component.html @@ -1,38 +1,93 @@ -
- -
- -
-
-
-
-
-
+
+ +
+ +
- - - - - - - - - - + +
+ +
+
+
+
+
- -
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+
+
+
+ + +
+
+
- -
- -
-
-
+ +
+ +
+ + +
-

- Government of Goa - Blockchain e-Licensing + +
+ +
+
+ + + + Powered by Hyperledger Besu +
+ +

+ Secure & Transparent + License Management

-

- Secure, Transparent, Immutable +

+ Experience the future of government services with blockchain-backed + license applications, instant verification, and tamper-proof records.

-
-
-
- - - -
-
- Blockchain Secured - Tamper-proof license records -
+ +
+
+ + + + Instant Verification
- -
-
- - - -
-
- Instant Verification - Real-time license validity -
+
+ + + + Tamper-Proof
- -
-
- - - -
-
- Multi-Dept Workflow - Streamlined approvals -
+
+ + + + Multi-Department
- -
-
- Hyperledger Besu Network - Live + +
+
+ + + + 100% Paperless Process +
+
+ + + + Apply Anytime, Anywhere +
+
+ + + + Real-time Status Tracking +
-
- -
-
- + + +
- -

+
+
diff --git a/frontend/src/app/layouts/auth-layout/auth-layout.component.scss b/frontend/src/app/layouts/auth-layout/auth-layout.component.scss index c6b74e8..be0e811 100644 --- a/frontend/src/app/layouts/auth-layout/auth-layout.component.scss +++ b/frontend/src/app/layouts/auth-layout/auth-layout.component.scss @@ -1,19 +1,21 @@ // ============================================================================= -// AUTH LAYOUT - World-Class Blockchain Login Experience -// DBIM v3.0 Compliant | GIGW 3.0 Accessible +// GOA GEL LANDING PAGE - World-Class Blockchain Government Portal +// Immersive, Graphical, Premium Design // ============================================================================= -// Skip Link (GIGW 3.0) +// Skip Link (GIGW 3.0 Accessibility) .skip-link { position: absolute; - top: -40px; - left: 0; - background: var(--dbim-blue-dark); + top: -50px; + left: 16px; + background: #1D0A69; color: white; - padding: 8px 16px; + padding: 12px 24px; text-decoration: none; - z-index: 1000; + z-index: 10000; + border-radius: 0 0 8px 8px; font-size: 14px; + font-weight: 500; &:focus { top: 0; @@ -21,438 +23,638 @@ } // ============================================================================= -// LAYOUT +// MAIN CONTAINER // ============================================================================= -.auth-layout { +.landing-page { min-height: 100vh; - display: flex; + width: 100%; position: relative; overflow: hidden; - background: linear-gradient(135deg, #0a0520 0%, #1D0A69 50%, #130640 100%); + background: #050208; } // ============================================================================= -// ANIMATED BACKGROUND +// HERO BACKGROUND - Immersive Blockchain Visualization // ============================================================================= -.animated-background { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; +.hero-background { + position: fixed; + inset: 0; pointer-events: none; z-index: 0; } -// Floating Blockchain Nodes -.node { +// Animated Gradient Mesh +.gradient-mesh { position: absolute; - width: 12px; - height: 12px; - background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%); + inset: 0; + background: + // Primary gradient orbs + radial-gradient(ellipse 80% 50% at 10% 20%, rgba(99, 102, 241, 0.25) 0%, transparent 50%), + radial-gradient(ellipse 60% 40% at 90% 80%, rgba(168, 85, 247, 0.2) 0%, transparent 50%), + radial-gradient(ellipse 50% 60% at 50% 50%, rgba(16, 185, 129, 0.08) 0%, transparent 50%), + radial-gradient(ellipse 70% 50% at 80% 20%, rgba(59, 130, 246, 0.15) 0%, transparent 45%), + // Base gradient + linear-gradient(135deg, #050208 0%, #0f0525 25%, #1a0840 50%, #120630 75%, #050208 100%); + animation: meshMove 30s ease-in-out infinite; +} + +@keyframes meshMove { + 0%, 100% { background-position: 0% 0%, 100% 100%, 50% 50%, 80% 20%, 0% 0%; } + 50% { background-position: 100% 100%, 0% 0%, 30% 70%, 20% 80%, 0% 0%; } +} + +// ============================================================================= +// BLOCKCHAIN NETWORK VISUALIZATION +// ============================================================================= +.blockchain-network { + position: absolute; + inset: 0; + overflow: hidden; +} + +// Blockchain Nodes +.bc-node { + position: absolute; + left: var(--x); + top: var(--y); + width: var(--size); + height: var(--size); border-radius: 50%; - box-shadow: 0 0 20px rgba(99, 102, 241, 0.5), 0 0 40px rgba(99, 102, 241, 0.3); - animation: float 6s ease-in-out infinite; + animation: nodeFloat 8s ease-in-out infinite; + animation-delay: var(--delay); + + &.primary { + background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%); + box-shadow: + 0 0 20px rgba(129, 140, 248, 0.6), + 0 0 40px rgba(129, 140, 248, 0.3), + 0 0 60px rgba(129, 140, 248, 0.15), + inset 0 0 10px rgba(255, 255, 255, 0.3); + + &::before { + content: ''; + position: absolute; + inset: -8px; + border-radius: 50%; + border: 1px solid rgba(129, 140, 248, 0.4); + animation: nodePulse 3s ease-out infinite; + animation-delay: var(--delay); + } + + &::after { + content: ''; + position: absolute; + inset: -16px; + border-radius: 50%; + border: 1px solid rgba(129, 140, 248, 0.2); + animation: nodePulse 3s ease-out infinite 0.5s; + } + } + + &.secondary { + background: linear-gradient(135deg, rgba(129, 140, 248, 0.6) 0%, rgba(167, 139, 250, 0.4) 100%); + box-shadow: + 0 0 15px rgba(129, 140, 248, 0.4), + 0 0 30px rgba(129, 140, 248, 0.2); + } +} + +@keyframes nodeFloat { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(15px, -20px) scale(1.05); } + 50% { transform: translate(-10px, 10px) scale(0.98); } + 75% { transform: translate(20px, 15px) scale(1.02); } +} + +@keyframes nodePulse { + 0% { transform: scale(1); opacity: 0.8; } + 100% { transform: scale(2.5); opacity: 0; } +} + +// Network Connection Lines +.network-connections { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.connection-line { + fill: none; + stroke: url(#lineGrad1); + stroke-width: 0.3; + stroke-linecap: round; + opacity: 0.6; + animation: lineGlow 4s ease-in-out infinite; + + &.secondary { + stroke: url(#lineGrad2); + stroke-width: 0.2; + opacity: 0.4; + animation-delay: 2s; + } +} + +@keyframes lineGlow { + 0%, 100% { opacity: 0.4; stroke-width: 0.2; } + 50% { opacity: 0.8; stroke-width: 0.4; } +} + +// Data Packets (moving dots) +.data-packet { + fill: #10B981; + filter: drop-shadow(0 0 4px #10B981) drop-shadow(0 0 8px rgba(16, 185, 129, 0.5)); +} + +// ============================================================================= +// FLOATING PARTICLES +// ============================================================================= +.particles { + position: absolute; + inset: 0; +} + +.particle { + position: absolute; + left: var(--x); + top: var(--y); + width: 3px; + height: 3px; + background: rgba(255, 255, 255, 0.4); + border-radius: 50%; + animation: particleFloat var(--duration) linear infinite; &::before { content: ''; position: absolute; + width: 100%; + height: 100%; + background: inherit; + border-radius: 50%; + filter: blur(2px); + } +} + +@keyframes particleFloat { + 0% { transform: translate(0, 0) scale(1); opacity: 0; } + 10% { opacity: 0.6; } + 50% { transform: translate(100px, -150px) scale(0.5); opacity: 0.3; } + 90% { opacity: 0.6; } + 100% { transform: translate(0, 0) scale(1); opacity: 0; } +} + +// ============================================================================= +// AMBIENT LIGHT EFFECTS +// ============================================================================= +.ambient-light { + position: absolute; + border-radius: 50%; + filter: blur(100px); + pointer-events: none; + + &.light-1 { + width: 600px; + height: 600px; + background: rgba(99, 102, 241, 0.15); + top: -200px; + left: -200px; + animation: ambientMove1 20s ease-in-out infinite; + } + + &.light-2 { + width: 500px; + height: 500px; + background: rgba(168, 85, 247, 0.12); + bottom: -150px; + right: -150px; + animation: ambientMove2 25s ease-in-out infinite; + } + + &.light-3 { + width: 400px; + height: 400px; + background: rgba(16, 185, 129, 0.08); top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 24px; - height: 24px; - border: 1px solid rgba(99, 102, 241, 0.3); - border-radius: 50%; - animation: pulse-ring 2s ease-out infinite; + animation: ambientMove3 30s ease-in-out infinite; } } -.node-1 { - top: 20%; - left: 15%; - animation-delay: 0s; +@keyframes ambientMove1 { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(100px, 50px); } } -.node-2 { - top: 30%; - left: 45%; - animation-delay: 1s; - width: 16px; - height: 16px; +@keyframes ambientMove2 { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(-80px, -60px); } } -.node-3 { - top: 15%; - right: 20%; - animation-delay: 2s; -} - -.node-4 { - bottom: 30%; - right: 25%; - animation-delay: 1.5s; - width: 10px; - height: 10px; -} - -.node-5 { - bottom: 25%; - left: 25%; - animation-delay: 0.5s; -} - -.node-6 { - top: 50%; - left: 35%; - animation-delay: 2.5s; - width: 8px; - height: 8px; -} - -// Connection Lines -.connections { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.3; -} - -.connection { - stroke: rgba(99, 102, 241, 0.4); - stroke-width: 0.1; - stroke-dasharray: 2 2; - animation: dash 20s linear infinite; -} - -// Gradient Overlay -.gradient-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: - radial-gradient(ellipse at 20% 30%, rgba(99, 102, 241, 0.15) 0%, transparent 50%), - radial-gradient(ellipse at 80% 70%, rgba(139, 92, 246, 0.15) 0%, transparent 50%), - radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 60%); +@keyframes ambientMove3 { + 0%, 100% { transform: translate(-50%, -50%) scale(1); } + 50% { transform: translate(-40%, -60%) scale(1.2); } } // ============================================================================= -// MAIN CONTAINER +// LANDING CONTENT // ============================================================================= -.auth-container { - display: flex; - width: 100%; - min-height: 100vh; +.landing-content { position: relative; z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; } // ============================================================================= -// LEFT SIDE - BRANDING +// TOP NAVIGATION // ============================================================================= -.auth-branding { - flex: 1; +.top-nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 48px; + background: rgba(5, 2, 8, 0.6); + backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.nav-brand { + display: flex; + align-items: center; + gap: 16px; +} + +.emblem-container { + width: 48px; + height: 48px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(168, 85, 247, 0.1) 100%); + border-radius: 12px; display: flex; align-items: center; justify-content: center; - padding: 48px; - position: relative; - - @media (max-width: 1024px) { - display: none; - } -} - -.branding-content { - max-width: 480px; - color: white; - animation: fadeInUp 0.6s ease-out; -} - -.emblem-wrapper { - width: 80px; - height: 80px; - background: rgba(255, 255, 255, 0.1); - border-radius: 20px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 32px; - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + border: 1px solid rgba(99, 102, 241, 0.3); .goa-emblem { - width: 56px; - height: 56px; + width: 32px; + height: 32px; filter: brightness(0) invert(1); + opacity: 0.9; } } -.brand-title { - margin: 0 0 16px; - - .title-line { - display: block; - font-size: 16px; - font-weight: 500; - color: rgba(255, 255, 255, 0.7); - letter-spacing: 0.05em; - text-transform: uppercase; - margin-bottom: 8px; - } - - .title-highlight { - display: block; - font-size: 36px; - font-weight: 700; - background: linear-gradient(135deg, #FFFFFF 0%, #A5B4FC 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - line-height: 1.2; - } -} - -.brand-tagline { - font-size: 18px; - color: rgba(255, 255, 255, 0.6); - margin: 0 0 48px; - font-weight: 400; -} - -// Features Grid -.features-grid { +.brand-text { display: flex; flex-direction: column; - gap: 20px; - margin-bottom: 48px; -} + gap: 2px; -.feature-item { - display: flex; - align-items: flex-start; - gap: 16px; - padding: 16px; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - backdrop-filter: blur(5px); - transition: all 0.2s ease; - - &:hover { - background: rgba(255, 255, 255, 0.08); - transform: translateX(4px); - } -} - -.feature-icon { - width: 44px; - height: 44px; - background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%); - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - svg { - width: 24px; - height: 24px; - color: #A5B4FC; - } -} - -.feature-text { - display: flex; - flex-direction: column; - gap: 4px; - - .feature-title { + .brand-name { font-size: 15px; font-weight: 600; color: white; + letter-spacing: 0.02em; } - .feature-desc { - font-size: 13px; - color: rgba(255, 255, 255, 0.5); + .brand-subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + font-weight: 400; } } -// Network Status -.network-status { +.nav-status { + .status-indicator { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + background: rgba(16, 185, 129, 0.1); + border-radius: 50px; + border: 1px solid rgba(16, 185, 129, 0.2); + } + + .status-dot { + width: 8px; + height: 8px; + background: #10B981; + border-radius: 50%; + box-shadow: 0 0 8px #10B981, 0 0 16px rgba(16, 185, 129, 0.5); + animation: statusBlink 2s ease-in-out infinite; + } + + .status-label { + font-size: 12px; + color: #34d399; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + } +} + +@keyframes statusBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +// ============================================================================= +// HERO SECTION +// ============================================================================= +.hero-section { + flex: 1; + display: grid; + grid-template-columns: 1fr minmax(400px, 480px); + gap: 40px; + padding: 40px 48px; + align-items: center; + width: 100%; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + gap: 40px; + padding: 32px; + text-align: center; + } + + @media (max-width: 600px) { + padding: 20px; + gap: 28px; + } +} + +// ============================================================================= +// HERO CONTENT (Left Side) +// ============================================================================= +.hero-content { + animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1); +} + +.hero-badge { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(168, 85, 247, 0.1) 100%); + border-radius: 50px; + border: 1px solid rgba(99, 102, 241, 0.3); + margin-bottom: 28px; + + svg { + width: 18px; + height: 18px; + color: #818cf8; + } + + span { + font-size: 13px; + color: #c7d2fe; + font-weight: 500; + } +} + +.hero-title { + margin: 0 0 24px; + + .title-line-1 { + display: block; + font-size: 20px; + font-weight: 600; + color: #c7d2fe; + text-transform: uppercase; + letter-spacing: 0.2em; + margin-bottom: 12px; + } + + .title-line-2 { + display: block; + font-size: clamp(36px, 5vw, 56px); + font-weight: 800; + background: linear-gradient(135deg, #FFFFFF 0%, #e0e7ff 40%, #c7d2fe 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.1; + letter-spacing: -0.02em; + } +} + +.hero-description { + font-size: 18px; + line-height: 1.7; + color: rgba(255, 255, 255, 0.7); + margin: 0 0 36px; + max-width: 540px; + + @media (max-width: 1200px) { + max-width: 100%; + } +} + +// Feature Pills +.feature-pills { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 48px; + + @media (max-width: 1200px) { + justify-content: center; + } +} + +.feature-pill { display: flex; align-items: center; - gap: 12px; - padding: 12px 16px; - background: rgba(16, 185, 129, 0.1); + gap: 8px; + padding: 12px 20px; + background: rgba(255, 255, 255, 0.05); border-radius: 50px; - border: 1px solid rgba(16, 185, 129, 0.2); - width: fit-content; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(99, 102, 241, 0.4); + transform: translateY(-2px); + } + + svg { + width: 18px; + height: 18px; + color: #10B981; + } + + span { + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + } } -.status-dot { - width: 10px; - height: 10px; - background: #10B981; - border-radius: 50%; - box-shadow: 0 0 8px #10B981; - animation: pulse 2s infinite; -} - -.status-text { - font-size: 13px; - color: rgba(255, 255, 255, 0.8); - font-weight: 500; -} - -.status-badge { - padding: 2px 8px; - background: rgba(16, 185, 129, 0.2); - border-radius: 4px; - font-size: 11px; - font-weight: 600; - color: #10B981; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -// ============================================================================= -// RIGHT SIDE - AUTH CONTENT -// ============================================================================= -.auth-content { - width: 480px; - min-width: 480px; +// Benefits List +.benefits-list { display: flex; flex-direction: column; + gap: 12px; + padding: 24px 28px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + + @media (max-width: 1200px) { + display: inline-flex; + align-items: flex-start; + } + + @media (max-width: 600px) { + padding: 20px; + gap: 10px; + } +} + +.benefit-item { + display: flex; + align-items: center; + gap: 14px; + padding: 8px 0; + transition: all 0.2s ease; + + &:hover { + transform: translateX(4px); + } + + svg { + width: 22px; + height: 22px; + flex-shrink: 0; + color: #10B981; + } + + span { + font-size: 15px; + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + } +} + +// ============================================================================= +// LOGIN CARD (Right Side) - Glassmorphism Design +// ============================================================================= +.login-container { + display: flex; justify-content: center; - padding: 48px; - background: white; - position: relative; + align-items: center; + animation: fadeInRight 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both; @media (max-width: 1024px) { width: 100%; - min-width: auto; max-width: 480px; - margin: auto; + margin: 0 auto; + } +} + +.login-card { + width: 100%; + padding: 36px 32px; + background: linear-gradient(135deg, rgba(15, 10, 30, 0.9) 0%, rgba(20, 15, 40, 0.95) 100%); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: 24px; + box-shadow: + 0 25px 80px rgba(0, 0, 0, 0.6), + 0 10px 30px rgba(0, 0, 0, 0.4), + inset 0 1px 1px rgba(255, 255, 255, 0.05); + position: relative; + overflow: hidden; + + // Subtle gradient border + &::before { + content: ''; + position: absolute; + inset: 0; border-radius: 24px; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + padding: 1px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.4) 0%, rgba(168, 85, 247, 0.2) 50%, rgba(16, 185, 129, 0.3) 100%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; } - @media (max-width: 520px) { - margin: 16px; - width: calc(100% - 32px); - padding: 32px 24px; + @media (max-width: 600px) { + padding: 28px 24px; border-radius: 20px; } } -.auth-card { - animation: fadeIn 0.4s ease-out; +.card-glow { + position: absolute; + top: -80px; + right: -80px; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 70%); + pointer-events: none; } // ============================================================================= // FOOTER // ============================================================================= -.auth-footer { - margin-top: auto; - padding-top: 24px; - text-align: center; +.landing-footer { + padding: 20px 48px; + background: rgba(5, 2, 8, 0.8); + backdrop-filter: blur(10px); + border-top: 1px solid rgba(255, 255, 255, 0.06); +} - .copyright { - font-size: 12px; - color: var(--dbim-grey-2); - margin: 0 0 8px; +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + + @media (max-width: 768px) { + flex-direction: column; + gap: 12px; + text-align: center; + } +} + +.copyright { + margin: 0; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +.footer-links { + display: flex; + gap: 24px; + + a { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + text-decoration: none; + transition: color 0.2s ease; + + &:hover { + color: #818cf8; + } } - .footer-links { - display: flex; + @media (max-width: 768px) { + gap: 16px; + flex-wrap: wrap; justify-content: center; - align-items: center; - gap: 12px; - - a { - font-size: 12px; - color: var(--dbim-grey-3); - text-decoration: none; - transition: color 0.2s ease; - - &:hover { - color: var(--dbim-blue-dark); - } - } - - .divider { - color: var(--dbim-grey-1); - font-size: 10px; - } } } // ============================================================================= // ANIMATIONS // ============================================================================= -@keyframes float { - 0%, 100% { - transform: translateY(0) translateX(0); - } - 25% { - transform: translateY(-20px) translateX(10px); - } - 50% { - transform: translateY(0) translateX(20px); - } - 75% { - transform: translateY(20px) translateX(10px); - } -} - -@keyframes pulse-ring { - 0% { - transform: translate(-50%, -50%) scale(1); - opacity: 1; - } - 100% { - transform: translate(-50%, -50%) scale(2); - opacity: 0; - } -} - -@keyframes dash { - to { - stroke-dashoffset: 100; - } -} - -@keyframes pulse { - 0%, 100% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.7; - transform: scale(1.1); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - @keyframes fadeInUp { from { opacity: 0; - transform: translateY(20px); + transform: translateY(30px); } to { opacity: 1; @@ -460,17 +662,30 @@ } } -// ============================================================================= -// RESPONSIVE -// ============================================================================= -@media (max-width: 1024px) { - .auth-layout { - align-items: center; - justify-content: center; +@keyframes fadeInRight { + from { + opacity: 0; + transform: translateX(30px); } - - .auth-container { - min-height: auto; - width: auto; + to { + opacity: 1; + transform: translateX(0); + } +} + +// ============================================================================= +// RESPONSIVE ADJUSTMENTS +// ============================================================================= +@media (max-width: 768px) { + .top-nav { + padding: 16px 20px; + + .nav-status { + display: none; + } + } + + .brand-text .brand-subtitle { + display: none; } } diff --git a/frontend/src/app/layouts/auth-layout/auth-layout.component.ts b/frontend/src/app/layouts/auth-layout/auth-layout.component.ts index edcb267..2de024f 100644 --- a/frontend/src/app/layouts/auth-layout/auth-layout.component.ts +++ b/frontend/src/app/layouts/auth-layout/auth-layout.component.ts @@ -8,4 +8,6 @@ import { RouterModule } from '@angular/router'; templateUrl: './auth-layout.component.html', styleUrl: './auth-layout.component.scss', }) -export class AuthLayoutComponent {} +export class AuthLayoutComponent { + readonly currentYear = new Date().getFullYear(); +} diff --git a/frontend/src/app/layouts/main-layout/main-layout.component.html b/frontend/src/app/layouts/main-layout/main-layout.component.html index b9a5e1f..574c482 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.component.html +++ b/frontend/src/app/layouts/main-layout/main-layout.component.html @@ -32,6 +32,25 @@
+ + @if (userType() === 'DEPARTMENT' && (currentUser | async); as user) { +
+ @if (sidenavOpened()) { +
+ business +
+
+ Department Portal + {{ departmentName() || user.name }} +
+ } @else { +
+ business +
+ } +
+ } +
Timestamp{{ row.timestamp | date: 'medium' }} + @if (row.timestamp || row.createdAt) { + {{ (row.timestamp || row.createdAt) | date: 'medium' }} + } @else { + - + } + Events
- @for (event of row.events.slice(0, 2); track event) { + @for (event of (row.events || []).slice(0, 2); track event) { {{ formatEvent(event) }} } - @if (row.events.length > 2) { - +{{ row.events.length - 2 }} + @if ((row.events?.length || 0) > 2) { + +{{ (row.events?.length || 0) - 2 }} }
Stages - {{ row.stages?.length || 0 }} stages + {{ row.definition?.stages?.length || 0 }} stages