583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
|
|
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<LicenseRequest> {
|
||
|
|
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<PaginatedResult<LicenseRequest>> {
|
||
|
|
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<string, string> = {
|
||
|
|
'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<LicenseRequest> {
|
||
|
|
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<LicenseRequest> {
|
||
|
|
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<PaginatedResult<LicenseRequest>> {
|
||
|
|
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<LicenseRequest>;
|
||
|
|
}
|
||
|
|
|
||
|
|
async submit(id: string): Promise<LicenseRequest> {
|
||
|
|
this.logger.debug(`Submitting license request: ${id}`);
|
||
|
|
|
||
|
|
const request = await this.findById(id);
|
||
|
|
|
||
|
|
if (request.status !== LicenseRequestStatus.DRAFT) {
|
||
|
|
const statusMessages: Record<string, string> = {
|
||
|
|
[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<LicenseRequest> {
|
||
|
|
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<LicenseRequest> {
|
||
|
|
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<string, any>): Promise<LicenseRequest> {
|
||
|
|
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<TimelineEventDto[]> {
|
||
|
|
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<LicenseRequest> {
|
||
|
|
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, LicenseRequestStatus[]> = {
|
||
|
|
[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<LicenseRequest> = { 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}`;
|
||
|
|
}
|
||
|
|
}
|