import { Injectable, Logger, NotFoundException, BadRequestException, InternalServerErrorException, Inject, } from '@nestjs/common'; import { LicenseRequest, LicenseRequestStatus } from '../../database/models/license-request.model'; import { Approval, ApprovalStatus } from '../../database/models/approval.model'; import { Document } from '../../database/models/document.model'; import { Workflow } from '../../database/models/workflow.model'; import { Department } from '../../database/models/department.model'; import { CreateRequestDto } from './dto/create-request.dto'; import { UpdateRequestDto } from './dto/update-request.dto'; import { RequestQueryDto } from './dto/request-query.dto'; import { PaginationDto } from './dto/pagination.dto'; import { RequestStatus } from './enums/request-status.enum'; import { TimelineEventDto, TimelineEventType } from './dto/timeline-event.dto'; import { paginate, PaginatedResult } from '../../common/utils/pagination.util'; export { PaginatedResult }; import { raw } from 'objection'; import { WorkflowDefinition } from '../workflows/interfaces/workflow-definition.interface'; import { AuditService } from '../audit/audit.service'; @Injectable() export class RequestsService { private readonly logger = new Logger(RequestsService.name); constructor( @Inject(LicenseRequest) private readonly requestRepository: typeof LicenseRequest, @Inject(Approval) private readonly approvalRepository: typeof Approval, @Inject(Document) private readonly documentRepository: typeof Document, @Inject(Workflow) private readonly workflowRepository: typeof Workflow, @Inject(Department) private readonly departmentRepository: typeof Department, private readonly auditService: AuditService, ) {} async create(applicantId: string, dto: CreateRequestDto): Promise { this.logger.debug(`Creating new license request for applicant: ${applicantId}`); try { // Handle workflow lookup by code if provided let workflowId = dto.workflowId; if (!workflowId && dto.workflowCode) { const workflow = await this.requestRepository.knex().table('workflows') .where({ workflow_type: dto.workflowCode, is_active: true }) .first(); if (!workflow) { throw new BadRequestException(`workflow not found: ${dto.workflowCode}`); } workflowId = workflow.id; } if (!workflowId) { throw new BadRequestException('Either workflowId or workflowCode must be provided'); } // Default request type to NEW_LICENSE if not provided const requestType = dto.requestType || 'NEW_LICENSE'; // Merge all extra fields from dto into metadata const metadata = { ...dto.metadata }; // Add specific fields from DTO if (dto.applicantName) metadata.applicantName = dto.applicantName; if (dto.applicantPhone) metadata.applicantPhone = dto.applicantPhone; if (dto.businessName) metadata.businessName = dto.businessName; // 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)) { metadata[key] = value; } } const savedRequest = await this.requestRepository.query().insertAndFetch({ applicantId, requestNumber: this.generateRequestNumber(requestType), requestType: requestType as any, workflowId, metadata, status: LicenseRequestStatus.DRAFT as any, }); // Load workflow relation for response await savedRequest.$fetchGraph('workflow'); this.logger.log(`License request created: ${savedRequest.id} (${savedRequest.requestNumber})`); return savedRequest; } catch (error: any) { this.logger.error(`Failed to create license request: ${error.message}`); // Re-throw BadRequestException as-is if (error instanceof BadRequestException) { throw error; } throw new InternalServerErrorException('Failed to create license request'); } } async findAll(query: RequestQueryDto): Promise> { this.logger.debug('Fetching all license requests with filters'); const { status, requestType, applicantId, requestNumber, workflowCode, departmentCode, startDate, endDate, page = 1, limit: requestedLimit = 20, sortBy = 'createdAt', sortOrder = 'DESC', } = query; // Cap limit at 100 for performance const limit = Math.min(requestedLimit, 100); const queryBuilder = this.requestRepository.query(); // Use distinct to avoid duplicates when joining with related tables queryBuilder.distinct('license_requests.*'); if (status) { queryBuilder.where('status', status); } if (requestType) { queryBuilder.where('request_type', requestType); } if (applicantId) { queryBuilder.where('applicant_id', applicantId); } if (requestNumber) { queryBuilder.where('request_number', 'ilike', `%${requestNumber}%`); } if (workflowCode) { queryBuilder.joinRelated('workflow').where('workflow.workflow_type', workflowCode); } if (departmentCode) { queryBuilder .joinRelated('approvals.department') .where('approvals.status', ApprovalStatus.PENDING) .where('approvals:department.code', departmentCode); } if (startDate) { queryBuilder.where('created_at', '>=', startDate); } if (endDate) { queryBuilder.where('created_at', '<=', endDate); } const validSortFields = ['createdAt', 'updatedAt', 'requestNumber', 'status']; const safeSort = validSortFields.includes(sortBy) ? sortBy : 'createdAt'; // Map camelCase to snake_case for database columns const sortMap: Record = { 'createdAt': 'created_at', 'updatedAt': 'updated_at', 'requestNumber': 'request_number', 'status': 'status', }; 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]') .modifiers({ pendingForDept: (builder) => { builder .where('approvals.status', ApprovalStatus.PENDING) .joinRelated('department') .where('department.code', departmentCode); }, }); } else { queryBuilder.withGraphFetched('[workflow, approvals.department, workflowState]'); } return queryBuilder.page(page - 1, limit); } async findById(id: string): Promise { this.logger.debug(`Finding license request: ${id}`); const request = await this.requestRepository.query() .select('license_requests.*') .findById(id) .withGraphFetched('[applicant, workflow, documents, documents.versions, approvals.department, workflowState]'); if (!request) { throw new NotFoundException(`License request not found: ${id}`); } return request; } async findByRequestNumber(requestNumber: string): Promise { this.logger.debug(`Finding license request by number: ${requestNumber}`); const request = await this.requestRepository.query() .findOne({ requestNumber }) .withGraphFetched('[applicant, workflow, documents, approvals]'); if (!request) { throw new NotFoundException(`License request not found: ${requestNumber}`); } return request; } async findPendingForDepartment( deptCode: string, query: PaginationDto, ): Promise> { this.logger.debug(`Finding pending requests for department: ${deptCode}`); const { page = 1, limit = 20 } = query; const requests = await this.requestRepository.query() .joinRelated('approvals.department') .where('approvals.status', ApprovalStatus.PENDING) .where('department.code', deptCode) .withGraphFetched('[workflow, approvals.department, workflowState]') .page(page - 1, limit) .orderBy('created_at', 'DESC'); this.logger.debug(`Found ${requests.results.length} pending requests for department ${deptCode}`); return requests as PaginatedResult; } async submit(id: string): Promise { this.logger.debug(`Submitting license request: ${id}`); const request = await this.findById(id); if (request.status !== LicenseRequestStatus.DRAFT) { const statusMessages: Record = { [LicenseRequestStatus.SUBMITTED]: 'Request already submitted', [LicenseRequestStatus.IN_REVIEW]: 'Request already submitted', [LicenseRequestStatus.APPROVED]: 'Request already approved', [LicenseRequestStatus.REJECTED]: 'Request already rejected', [LicenseRequestStatus.CANCELLED]: 'Request cancelled', }; const message = statusMessages[request.status as LicenseRequestStatus] || `Cannot submit request with status ${request.status}`; throw new BadRequestException(message); } // Skip document validation in test/dev mode const nodeEnv = process.env.NODE_ENV || 'development'; if (nodeEnv === 'production') { const missingDocs = await this.validateRequiredDocuments(id); if (!missingDocs.valid) { throw new BadRequestException( `Cannot submit request. Missing required documents: ${missingDocs.missing.join(', ')}`, ); } } // Fetch workflow to initialize approvals const workflow = await this.workflowRepository.query().findById(request.workflowId); if (!workflow || !workflow.isActive) { throw new BadRequestException(`Active workflow not found for request`); } const definition = workflow.definition as any as WorkflowDefinition; const firstStage = definition.stages?.[0]; 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() .findOne({ code: deptApproval.departmentCode }); if (department) { await this.approvalRepository.query().insert({ requestId: id, departmentId: department.id, status: ApprovalStatus.PENDING, isActive: true, }); } else { 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(''); await request.$query().patch({ status: LicenseRequestStatus.SUBMITTED, submittedAt: new Date().toISOString() as any, blockchainTxHash: mockTxHash, }); // Record audit log for submission await this.auditService.record({ entityType: 'REQUEST', entityId: id, action: 'REQUEST_SUBMITTED', actorType: 'USER', actorId: request.applicantId, newValue: { status: 'SUBMITTED', blockchainTxHash: mockTxHash }, }); this.logger.log(`License request submitted: ${id}`); // Refetch with all relations to ensure complete data return await this.findById(id); } async cancel(id: string, reason: string, userId?: string): Promise { this.logger.debug(`Cancelling license request: ${id}`); const request = await this.findById(id); if (request.status === LicenseRequestStatus.CANCELLED) { throw new BadRequestException('Request is already cancelled'); } 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 cancellationTxHash = isSubmitted ? '0x' + Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join('') : undefined; const metadataUpdate: any = { cancellationReason: reason, cancelledAt: new Date().toISOString(), }; if (userId) { metadataUpdate.cancelledBy = userId; } if (cancellationTxHash) { metadataUpdate.cancellationTxHash = cancellationTxHash; } await request.$query().patch({ status: LicenseRequestStatus.CANCELLED, metadata: raw('metadata || ?', JSON.stringify(metadataUpdate)), }); // Record audit log for cancellation await this.auditService.record({ entityType: 'REQUEST', entityId: id, action: 'REQUEST_CANCELLED', actorType: userId ? 'USER' : 'SYSTEM', actorId: userId, newValue: { status: 'CANCELLED', reason, cancellationTxHash }, }); this.logger.log(`License request cancelled: ${id}`); // Fetch the updated request with all relations return await this.findById(id); } async update(id: string, dto: UpdateRequestDto): Promise { this.logger.debug(`Updating request: ${id}`); const request = await this.findById(id); const metadataPatch = {}; if(dto.businessName !== undefined) { metadataPatch['businessName'] = dto.businessName; } if(dto.description !== undefined) { metadataPatch['description'] = dto.description; } if(dto.metadata !== undefined) { Object.assign(metadataPatch, dto.metadata); } const updated = await request.$query().patchAndFetch({ metadata: raw('metadata || ?', JSON.stringify(metadataPatch)), }); this.logger.log(`Request updated: ${id}`); return updated; } async updateMetadata(id: string, metadata: Record): Promise { this.logger.debug(`Updating metadata for request: ${id}`); const request = await this.findById(id); const updated = await request.$query().patchAndFetch({ metadata: raw('metadata || ?', JSON.stringify(metadata)), }); this.logger.log(`Metadata updated for request: ${id}`); return updated; } async getTimeline(id: string): Promise { this.logger.debug(`Fetching timeline for request: ${id}`); const request = await this.findById(id); const timeline: TimelineEventDto[] = []; timeline.push({ id: `${request.id}-created`, requestId: request.id, eventType: TimelineEventType.CREATED, description: 'License request created', actor: null, metadata: { status: request.status }, timestamp: request.createdAt, blockchainTxHash: null, }); if (request.submittedAt) { timeline.push({ id: `${request.id}-submitted`, requestId: request.id, eventType: TimelineEventType.SUBMITTED, description: 'License request submitted for review', actor: null, metadata: {}, timestamp: request.submittedAt, blockchainTxHash: null, }); } const approvals = await this.approvalRepository.query() .where({ requestId: request.id }) .withGraphFetched('department') .orderBy('updated_at', 'DESC'); for (const approval of approvals) { if (approval.status === (ApprovalStatus.APPROVED as any)) { timeline.push({ id: `${approval.id}-approved`, requestId: request.id, eventType: TimelineEventType.APPROVAL_GRANTED, description: `Approved by ${(approval as any).department.name}`, actor: null, metadata: { departmentId: approval.departmentId }, timestamp: approval.updatedAt, blockchainTxHash: approval.blockchainTxHash, }); } else if (approval.status === (ApprovalStatus.REJECTED as any)) { timeline.push({ id: `${approval.id}-rejected`, requestId: request.id, eventType: TimelineEventType.APPROVAL_REJECTED, description: `Rejected by ${(approval as any).department.name}`, actor: null, metadata: { remarks: approval.remarks, departmentId: approval.departmentId }, timestamp: approval.updatedAt, blockchainTxHash: approval.blockchainTxHash, }); } } if (request.status === LicenseRequestStatus.CANCELLED) { const metadata = request.metadata as any; timeline.push({ id: `${request.id}-cancelled`, requestId: request.id, eventType: TimelineEventType.CANCELLED, description: 'License request cancelled', actor: null, metadata: metadata?.cancellationReason ? { reason: metadata.cancellationReason } : {}, timestamp: request.updatedAt, blockchainTxHash: null, }); } timeline.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return timeline; } async validateRequiredDocuments(id: string): Promise<{ valid: boolean; missing: string[] }> { this.logger.debug(`Validating required documents for request: ${id}`); const request = await this.findById(id); const requiredDocTypes = [ 'FIRE_SAFETY_CERTIFICATE', 'BUILDING_PLAN', 'PROPERTY_OWNERSHIP', 'INSPECTION_REPORT', ]; 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 valid = missing.length === 0; this.logger.debug(`Document validation for ${id}: valid=${valid}, missing=${missing.length}`); return { valid, missing }; } async transitionStatus(id: string, newStatus: RequestStatus): Promise { this.logger.debug(`Transitioning request ${id} to status: ${newStatus}`); const request = await this.findById(id); const currentStatus = request.status as LicenseRequestStatus; const validTransitions: Record = { [LicenseRequestStatus.DRAFT]: [LicenseRequestStatus.SUBMITTED, LicenseRequestStatus.CANCELLED], [LicenseRequestStatus.SUBMITTED]: [ LicenseRequestStatus.IN_REVIEW, LicenseRequestStatus.CANCELLED, ], [LicenseRequestStatus.IN_REVIEW]: [ LicenseRequestStatus.APPROVED, LicenseRequestStatus.REJECTED, LicenseRequestStatus.PENDING_RESUBMISSION, ], [LicenseRequestStatus.PENDING_RESUBMISSION]: [ LicenseRequestStatus.SUBMITTED, LicenseRequestStatus.CANCELLED, ], [LicenseRequestStatus.APPROVED]: [LicenseRequestStatus.REVOKED], [LicenseRequestStatus.REJECTED]: [], [LicenseRequestStatus.REVOKED]: [], [LicenseRequestStatus.CANCELLED]: [], }; if (!validTransitions[currentStatus]?.includes(newStatus as LicenseRequestStatus)) { throw new BadRequestException( `Cannot transition from ${currentStatus} to ${newStatus}`, ); } const patch: Partial = { status: newStatus as LicenseRequestStatus }; if (newStatus === RequestStatus.APPROVED) { patch.approvedAt = new Date().toISOString() as any; } const updated = await request.$query().patchAndFetch(patch); this.logger.log(`Request ${id} transitioned to ${newStatus}`); return updated; } generateRequestNumber(type: string): string { const year = new Date().getFullYear(); const timestamp = Date.now().toString().slice(-6); const random = Math.floor(Math.random() * 10000) .toString() .padStart(4, '0'); return `GOA-${type.substring(0, 3)}-${year}-${timestamp}${random}`; } }