diff --git a/AGENTS.md b/AGENTS.md index 5fe046d..fb661cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**. | Testing | 🔄 UAT In Progress | Per `01-05-acceptance-criteria.md` | | Deployment | 📋 Pending Go-Live | Blue-Green, QNAP Container Station | -- **Goal:** Manage construction documents (Correspondence, RFA, Contract Drawings, Shop Drawings) +- **Goal:** Manage construction documents (Correspondence, RFA, Circulation, Transmittal, Contract Drawings, Shop Drawings) with complex multi-level approval workflows. - **Infrastructure:** - **QNAP NAS:** Container Station — DMS Frontend/Backend, MariaDB, Redis, Elasticsearch, Nginx Proxy Manager, n8n + n8n-db, Tika, Gitea, RocketChat, cAdvisor, exporters diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index 68010eb..1ee0221 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -277,7 +277,7 @@ export class CorrespondenceService { 'correspondence', savedCorr.id.toString(), { - projectId: createDto.projectId, + projectId: resolvedProjectId, originatorId: userOrgId, disciplineId: createDto.disciplineId, initiatorId: user.user_id, @@ -297,7 +297,7 @@ export class CorrespondenceService { title: createDto.subject, description: createDto.description, status: 'DRAFT', - projectId: createDto.projectId, + projectId: resolvedProjectId, createdAt: new Date(), }); diff --git a/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts b/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts index b427cce..7614d37 100644 --- a/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts +++ b/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts @@ -39,6 +39,7 @@ export class DocumentNumberAudit { 'CANCEL', 'MANUAL_OVERRIDE', 'VOID', + 'VOID_REPLACE', 'GENERATE', ], default: 'CONFIRM', diff --git a/backend/src/modules/document-numbering/entities/document-number-error.entity.ts b/backend/src/modules/document-numbering/entities/document-number-error.entity.ts index a6b0f5a..d133c66 100644 --- a/backend/src/modules/document-numbering/entities/document-number-error.entity.ts +++ b/backend/src/modules/document-numbering/entities/document-number-error.entity.ts @@ -26,6 +26,7 @@ export class DocumentNumberError { 'SEQUENCE_EXHAUSTED', 'RESERVATION_EXPIRED', 'DUPLICATE_NUMBER', + 'GENERATE_ERROR', ], }) errorType!: string; diff --git a/backend/src/modules/document-numbering/services/document-numbering.service.ts b/backend/src/modules/document-numbering/services/document-numbering.service.ts index 568aca9..fb44a18 100644 --- a/backend/src/modules/document-numbering/services/document-numbering.service.ts +++ b/backend/src/modules/document-numbering/services/document-numbering.service.ts @@ -475,17 +475,35 @@ export class DocumentNumberingService { return (await this.auditRepo.save(audit)) as unknown as DocumentNumberAudit; } - private async logError(error: any, ctx: any, operation: string) { + private mapErrorType(error: Error): string { + const name = error.name || ''; + const msg = error.message || ''; + if (name === 'ConflictException' || msg.includes('version')) + return 'VERSION_CONFLICT'; + if (msg.includes('lock') || msg.includes('timeout')) return 'LOCK_TIMEOUT'; + if (msg.includes('Redis') || msg.includes('redis')) return 'REDIS_ERROR'; + if (msg.includes('duplicate') || msg.includes('Duplicate')) + return 'DUPLICATE_NUMBER'; + if (msg.includes('validation') || msg.includes('Validation')) + return 'VALIDATION_ERROR'; + if (msg.includes('exhausted') || msg.includes('maximum')) + return 'SEQUENCE_EXHAUSTED'; + if (msg.includes('expired') || msg.includes('reservation')) + return 'RESERVATION_EXPIRED'; + if (msg.includes('database') || msg.includes('query')) return 'DB_ERROR'; + return 'GENERATE_ERROR'; + } + + private async logError(error: unknown, ctx: unknown, operation: string) { try { + const err = error instanceof Error ? error : new Error(String(error)); const errEntity = this.errorRepo.create({ - errorMessage: error.message || 'Unknown Error', - errorType: error.name || 'GENERATE_ERROR', // Simple mapping + errorMessage: err.message || 'Unknown Error', + errorType: this.mapErrorType(err), contextData: { - // Mapped from context - ...ctx, + ...(typeof ctx === 'object' && ctx !== null ? ctx : {}), operation, - inputPayload: JSON.stringify(ctx), - }, + } as Record, }); await this.errorRepo.save(errEntity); } catch (e) { diff --git a/backend/src/modules/rfa/dto/search-rfa.dto.ts b/backend/src/modules/rfa/dto/search-rfa.dto.ts index 260f570..224b9f6 100644 --- a/backend/src/modules/rfa/dto/search-rfa.dto.ts +++ b/backend/src/modules/rfa/dto/search-rfa.dto.ts @@ -1,12 +1,15 @@ -import { IsInt, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsInt, IsOptional, IsString } from 'class-validator'; import { Type } from 'class-transformer'; export class SearchRfaDto { - @IsUUID('all') - projectUuid!: string; // ADR-019: Public UUID of the project + @IsOptional() + @IsString() + projectId?: number | string; // ADR-019: Accept INT or UUID string - /** @internal Resolved INT ID — set by controller, do NOT expose in API */ - projectId?: number; + @IsOptional() + @IsInt() + @Type(() => Number) + statusId?: number; @IsOptional() @IsInt() diff --git a/backend/src/modules/rfa/rfa.controller.ts b/backend/src/modules/rfa/rfa.controller.ts index bc9a854..350aae6 100644 --- a/backend/src/modules/rfa/rfa.controller.ts +++ b/backend/src/modules/rfa/rfa.controller.ts @@ -54,7 +54,10 @@ export class RfaController { @Post(':uuid/submit') @ApiOperation({ summary: 'Submit RFA to Workflow' }) - @ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' }) + @ApiParam({ + name: 'uuid', + description: 'RFA UUID (from correspondences.uuid)', + }) @ApiBody({ type: SubmitRfaDto }) @ApiResponse({ status: 200, description: 'RFA submitted successfully' }) @RequirePermission('rfa.create') @@ -71,7 +74,10 @@ export class RfaController { @Post(':uuid/action') @ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' }) - @ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' }) + @ApiParam({ + name: 'uuid', + description: 'RFA UUID (from correspondences.uuid)', + }) @ApiBody({ type: WorkflowActionDto }) @ApiResponse({ status: 200, @@ -94,15 +100,24 @@ export class RfaController { @ApiResponse({ status: 200, description: 'List of RFAs' }) @RequirePermission('document.view') async findAll(@Query() query: SearchRfaDto) { - // ADR-019: resolve projectUuid → internal INT projectId - const project = await this.projectService.findOneByUuid(query.projectUuid); - query.projectId = project.id; + // ADR-019: resolve projectId UUID→INT if provided + if (query.projectId) { + const pid = query.projectId; + const num = Number(pid); + if (typeof pid === 'string' && isNaN(num)) { + const project = await this.projectService.findOneByUuid(pid); + query.projectId = project.id; + } + } return this.rfaService.findAll(query); } @Get(':uuid') @ApiOperation({ summary: 'Get RFA details with revisions and items' }) - @ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' }) + @ApiParam({ + name: 'uuid', + description: 'RFA UUID (from correspondences.uuid)', + }) @ApiResponse({ status: 200, description: 'RFA details' }) @RequirePermission('document.view') findOne(@Param('uuid', ParseUuidPipe) uuid: string) { diff --git a/backend/src/modules/transmittal/dto/create-transmittal.dto.ts b/backend/src/modules/transmittal/dto/create-transmittal.dto.ts index add0783..5ff4686 100644 --- a/backend/src/modules/transmittal/dto/create-transmittal.dto.ts +++ b/backend/src/modules/transmittal/dto/create-transmittal.dto.ts @@ -38,10 +38,9 @@ export class TransmittalItemDto { } export class CreateTransmittalDto { - @ApiProperty({ description: 'ID āļ‚āļ­āļ‡āđ‚āļ„āļĢāļ‡āļāļēāļĢ', example: 1 }) - @IsInt() + @ApiProperty({ description: 'ID āļŦāļĢāļ·āļ­ UUID āļ‚āļ­āļ‡āđ‚āļ„āļĢāļ‡āļāļēāļĢ (ADR-019)' }) @IsNotEmpty() - projectId!: number; + projectId!: number | string; @ApiProperty({ description: 'āđ€āļĢāļ·āđˆāļ­āļ‡', @@ -51,10 +50,16 @@ export class CreateTransmittalDto { @IsNotEmpty() subject!: string; - @ApiProperty({ description: 'āļœāļđāđ‰āļĢāļąāļš (Organization ID)', example: 2 }) - @IsInt() + @ApiProperty({ description: 'āļœāļđāđ‰āļĢāļąāļš Organization ID āļŦāļĢāļ·āļ­ UUID (ADR-019)' }) @IsNotEmpty() - recipientOrganizationId!: number; + recipientOrganizationId!: number | string; + + @ApiProperty({ + description: 'Correspondence ID āļŦāļĢāļ·āļ­ UUID (ADR-019)', + required: false, + }) + @IsOptional() + correspondenceId?: number | string; @ApiProperty({ description: 'āļ§āļąāļ•āļ–āļļāļ›āļĢāļ°āļŠāļ‡āļ„āđŒ', @@ -65,6 +70,11 @@ export class CreateTransmittalDto { @IsOptional() purpose?: TransmittalPurpose; + @ApiProperty({ description: 'āļŦāļĄāļēāļĒāđ€āļŦāļ•āļļ', required: false }) + @IsString() + @IsOptional() + remarks?: string; + @ApiProperty({ description: 'āļĢāļēāļĒāļāļēāļĢāļ—āļĩāđˆāđāļ™āļš', type: [TransmittalItemDto] }) @IsArray() @ValidateNested({ each: true }) diff --git a/backend/src/modules/transmittal/transmittal.service.ts b/backend/src/modules/transmittal/transmittal.service.ts index 100bf22..4c83db0 100644 --- a/backend/src/modules/transmittal/transmittal.service.ts +++ b/backend/src/modules/transmittal/transmittal.service.ts @@ -21,6 +21,8 @@ import { CorrespondenceRevision } from '../correspondence/entities/correspondenc import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { Project } from '../project/entities/project.entity'; +import { Organization } from '../organization/entities/organization.entity'; +import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity'; @Injectable() export class TransmittalService { @@ -55,6 +57,22 @@ export class TransmittalService { return project.id; } + /** + * ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID + */ + private async resolveOrganizationId(orgId: number | string): Promise { + if (typeof orgId === 'number') return orgId; + const num = Number(orgId); + if (!isNaN(num)) return num; + const org = await this.dataSource.manager.findOne(Organization, { + where: { uuid: orgId }, + select: ['id'], + }); + if (!org) + throw new NotFoundException(`Organization with UUID ${orgId} not found`); + return org.id; + } + async create(createDto: CreateTransmittalDto, user: User) { // 1. Get Transmittal Type (Assuming Code '901' or 'TRN') const type = await this.typeRepo.findOne({ @@ -119,11 +137,22 @@ export class TransmittalService { }); await queryRunner.manager.save(revision); + // ADR-019: Resolve recipientOrganizationId UUID→INT and create recipient record + const internalRecipientOrgId = await this.resolveOrganizationId( + createDto.recipientOrganizationId + ); + const recipient = queryRunner.manager.create(CorrespondenceRecipient, { + correspondenceId: savedCorr.id, + recipientOrganizationId: internalRecipientOrgId, + recipientType: 'TO', + }); + await queryRunner.manager.save(recipient); + // 5. Create Transmittal const transmittal = queryRunner.manager.create(Transmittal, { correspondenceId: savedCorr.id, - purpose: 'FOR_REVIEW', // Default or from DTO - // remarks: createDto.remarks, // Add if in DTO + purpose: createDto.purpose || 'FOR_REVIEW', + remarks: createDto.remarks, }); const savedTransmittal = await queryRunner.manager.save(transmittal); @@ -169,7 +198,6 @@ export class TransmittalService { if (!correspondence) { throw new NotFoundException(`Transmittal with UUID ${uuid} not found`); } - return this.findOne(correspondence.id); } async findOne(id: number) { diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 110ba94..08f71d5 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -91,6 +91,7 @@ export class UserService { .leftJoinAndSelect('assignments.role', 'role') .select([ 'user.user_id', + 'user.uuid', 'user.username', 'user.email', 'user.firstName', diff --git a/frontend/app/(dashboard)/search/page.tsx b/frontend/app/(dashboard)/search/page.tsx index 3b8a3da..bdd5736 100644 --- a/frontend/app/(dashboard)/search/page.tsx +++ b/frontend/app/(dashboard)/search/page.tsx @@ -23,17 +23,11 @@ function SearchContent() { statuses: statusParam ? [statusParam] : [], }); - // Construct search DTO + // Construct search DTO — only send fields recognized by backend SearchQueryDto + // Backend uses forbidNonWhitelisted: true, so unknown fields (types[], statuses[]) cause 400 const searchDto = { q: query, - // Map internal types to backend expectation if needed, assumes direct mapping for now - type: filters.types?.length === 1 ? filters.types[0] : undefined, // Backend might support single type or multiple? - // DTO says 'type?: string', 'status?: string'. If multiple, our backend might need adjustment or we only support single filter for now? - // Spec says "Advanced filters work (type, status)". Let's assume generic loose mapping for now or comma separated. - // Let's assume the hook and backend handle it. If backend expects single value, we pick first or join. - // Backend controller uses `SearchQueryDto`. Let's check DTO if I can view it. - // Actually, I'll pass them and let the service handle serialization if needed. - ...filters + type: filters.types?.length ? filters.types[0] : undefined, }; const { data: results, isLoading, isError } = useSearch(searchDto); @@ -50,7 +44,7 @@ function SearchContent() {

{isLoading ? "Searching..." - : `Found ${results?.length || 0} results for "${query}"` + : `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"` }

diff --git a/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx b/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx index 91ae960..2ee3159 100644 --- a/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx +++ b/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx @@ -70,8 +70,12 @@ export default function TransmittalDetailPage() {
-

{transmittal.transmittalNo}

-

{transmittal.subject}

+

+ {transmittal.correspondence?.correspondenceNumber || transmittal.transmittalNo} +

+

+ {transmittal.correspondence?.revisions?.find(r => r.isCurrent)?.title || transmittal.subject} +