diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index 1fda2a2..c6e5f65 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -17,12 +17,15 @@ jobs: uses: actions/checkout@v4 - name: 📦 Install pnpm - run: npm install -g pnpm@10.32.1 + uses: pnpm/action-setup@v4 + with: + version: 10.32.1 - name: 🟢 Setup Node uses: actions/setup-node@v4 with: node-version: 20 + cache: "pnpm" - name: 📦 Install deps run: pnpm install --frozen-lockfile @@ -45,7 +48,7 @@ jobs: - name: 🧪 Run Tests & Coverage run: | - cd backend && pnpm test --watchAll=false + cd backend && pnpm test cd ../frontend && pnpm test run # ============================================================ diff --git a/.vscode/settings.json b/.vscode/settings.json index 3f228f6..ed48914 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "editor.fontSize": 18 + "editor.fontSize": 20 } diff --git a/CHANGELOG.md b/CHANGELOG.md index de36e6f..9fe8d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +### CI/CD & Deployment Simplification (2026-03-24) + +#### 🚀 **deploy.sh v2.0 — Rewrote deployment script** + +- **Changed**: Replaced 9-step blue-green deployment with 3-step direct deploy +- **Step 1**: Build Docker images (backend + frontend) from source +- **Step 2**: `docker compose -f [compose_file] up -d --force-recreate` +- **Step 3**: Health check on `backend` container +- **Removed**: Blue/green directory switching, NGINX switching, `current` file tracking +- **Reason**: QNAP setup uses a single stack — simultaneous blue/green was not viable with shared container names + +#### ⚙️ **ci-deploy.yml — CI pipeline improvements** + +- **Added**: `pnpm/action-setup@v4` + `cache: 'pnpm'` for faster installs +- **Fixed**: `--watchAll=false` removed from backend test command (not a valid Jest flag) +- **Fixed**: `mkdir -p /share/np-dms/app/logs` before deploy to prevent `tee` error +- **Simplified**: Removed `tee` + `PIPESTATUS` — `set -e` handles errors + ### Document Numbering System Fixes (2026-03-21) #### 🔢 **Template Management Hardening** diff --git a/backend/src/modules/rfa/dto/create-rfa.dto.ts b/backend/src/modules/rfa/dto/create-rfa.dto.ts index 65d28f7..df08ac0 100644 --- a/backend/src/modules/rfa/dto/create-rfa.dto.ts +++ b/backend/src/modules/rfa/dto/create-rfa.dto.ts @@ -8,6 +8,7 @@ import { IsObject, IsOptional, IsString, + Min, } from 'class-validator'; export class CreateRfaDto { @@ -30,12 +31,14 @@ export class CreateRfaDto { rfaTypeId!: number; @ApiProperty({ - description: 'ID ของสาขางาน (Discipline) ตาม Req 6B', + description: + 'ID ของสาขางาน (Discipline) ตาม Req 6B — Required per spec §3.3.4', example: 1, }) @IsInt() - @IsOptional() // Optional ไว้ก่อนเผื่อบางโครงการไม่บังคับ - disciplineId?: number; + @Min(1) + @IsNotEmpty() + disciplineId!: number; @ApiProperty({ description: 'หัวข้อเอกสาร', diff --git a/backend/src/modules/rfa/rfa-workflow.service.ts b/backend/src/modules/rfa/rfa-workflow.service.ts index aea9daa..0e10c8a 100644 --- a/backend/src/modules/rfa/rfa-workflow.service.ts +++ b/backend/src/modules/rfa/rfa-workflow.service.ts @@ -1,4 +1,14 @@ // File: src/modules/rfa/rfa-workflow.service.ts +// +// NOTE (Phase 4.1 Refactor): +// This service was written as an alternative workflow integration layer using the +// Unified WorkflowEngineService (ADR-001). It is currently NOT called by RfaController. +// Active workflow logic lives in RfaService.submit() / RfaService.processAction(). +// +// Reserved for Phase 3: When the Unified Workflow Engine is fully wired to RFA, +// RfaService workflow methods should be migrated here and RfaController updated +// to delegate to RfaWorkflowService. +// import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; diff --git a/backend/src/modules/rfa/rfa.controller.ts b/backend/src/modules/rfa/rfa.controller.ts index 350aae6..d9da71e 100644 --- a/backend/src/modules/rfa/rfa.controller.ts +++ b/backend/src/modules/rfa/rfa.controller.ts @@ -2,9 +2,13 @@ import { Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, Post, + Put, Query, UseGuards, } from '@nestjs/common'; @@ -20,6 +24,7 @@ import { import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; import { User } from '../user/entities/user.entity'; import { CreateRfaDto } from './dto/create-rfa.dto'; +import { UpdateRfaDto } from './dto/update-rfa.dto'; import { SubmitRfaDto } from './dto/submit-rfa.dto'; import { SearchRfaDto } from './dto/search-rfa.dto'; import { RfaService } from './rfa.service'; @@ -31,6 +36,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { RbacGuard } from '../../common/guards/rbac.guard'; import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { ProjectService } from '../project/project.service'; +import { UuidResolverService } from '../../common/services/uuid-resolver.service'; @ApiTags('RFA (Request for Approval)') @ApiBearerAuth() @@ -39,7 +45,8 @@ import { ProjectService } from '../project/project.service'; export class RfaController { constructor( private readonly rfaService: RfaService, - private readonly projectService: ProjectService + private readonly projectService: ProjectService, + private readonly uuidResolver: UuidResolverService ) {} @Post() @@ -99,17 +106,14 @@ export class RfaController { @ApiOperation({ summary: 'List all RFAs with pagination' }) @ApiResponse({ status: 200, description: 'List of RFAs' }) @RequirePermission('document.view') - async findAll(@Query() query: SearchRfaDto) { + async findAll(@Query() query: SearchRfaDto, @CurrentUser() user: User) { // 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; - } + query.projectId = await this.uuidResolver.resolveProjectId( + query.projectId + ); } - return this.rfaService.findAll(query); + return this.rfaService.findAll(query, user); } @Get(':uuid') @@ -123,4 +127,33 @@ export class RfaController { findOne(@Param('uuid', ParseUuidPipe) uuid: string) { return this.rfaService.findOneByUuid(uuid); } + + @Put(':uuid') + @ApiOperation({ summary: 'Update Draft RFA fields (EC-RFA-002: DFT only)' }) + @ApiParam({ name: 'uuid', description: 'RFA UUID' }) + @ApiBody({ type: UpdateRfaDto }) + @ApiResponse({ status: 200, description: 'RFA updated successfully' }) + @RequirePermission('rfa.create') + @Audit('rfa.update', 'rfa') + async update( + @Param('uuid', ParseUuidPipe) uuid: string, + @Body() updateDto: UpdateRfaDto, + @CurrentUser() user: User + ) { + return this.rfaService.update(uuid, updateDto, user); + } + + @Delete(':uuid') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Cancel Draft RFA (sets status to CC)' }) + @ApiParam({ name: 'uuid', description: 'RFA UUID' }) + @ApiResponse({ status: 200, description: 'RFA cancelled successfully' }) + @RequirePermission('rfa.create') + @Audit('rfa.cancel', 'rfa') + async cancel( + @Param('uuid', ParseUuidPipe) uuid: string, + @CurrentUser() user: User + ) { + return this.rfaService.cancel(uuid, user); + } } diff --git a/backend/src/modules/rfa/rfa.module.ts b/backend/src/modules/rfa/rfa.module.ts index ec8d242..84cfe9b 100644 --- a/backend/src/modules/rfa/rfa.module.ts +++ b/backend/src/modules/rfa/rfa.module.ts @@ -11,6 +11,7 @@ import { CorrespondenceType } from '../correspondence/entities/correspondence-ty import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity'; import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity'; +import { Organization } from '../organization/entities/organization.entity'; import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity'; import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; import { Discipline } from '../master/entities/discipline.entity'; @@ -60,6 +61,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module' RoutingTemplate, RoutingTemplateStep, CorrespondenceRecipient, + Organization, ]), DocumentNumberingModule, UserModule, diff --git a/backend/src/modules/rfa/rfa.service.ts b/backend/src/modules/rfa/rfa.service.ts index 0036402..f9b935a 100644 --- a/backend/src/modules/rfa/rfa.service.ts +++ b/backend/src/modules/rfa/rfa.service.ts @@ -23,6 +23,7 @@ import { RoutingTemplateStep } from '../correspondence/entities/routing-template import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity'; import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; import { Discipline } from '../master/entities/discipline.entity'; +import { Organization } from '../organization/entities/organization.entity'; import { User } from '../user/entities/user.entity'; import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaItem } from './entities/rfa-item.entity'; @@ -35,6 +36,7 @@ import { Rfa } from './entities/rfa.entity'; import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; import { CreateRfaDto } from './dto/create-rfa.dto'; import { SearchRfaDto } from './dto/search-rfa.dto'; +import { UpdateRfaDto } from './dto/update-rfa.dto'; // ------- Local type helpers (no-any ADR-019) ------- /** CorrespondenceRevision with the rfaRevision relation loaded at runtime */ @@ -94,6 +96,8 @@ export class RfaService { private templateRepo: Repository, @InjectRepository(RoutingTemplateStep) private templateStepRepo: Repository, + @InjectRepository(Organization) + private orgRepo: Repository, private numberingService: DocumentNumberingService, private userService: UserService, @@ -232,13 +236,40 @@ export class RfaService { throw new BadRequestException('User must belong to an organization'); } + // EC-RFA-001: Check for existing active RFA per Shop Drawing Revision + if (shopDrawingRevisionIds.length > 0) { + const conflictingItems = await this.rfaItemRepo + .createQueryBuilder('item') + .innerJoin('item.rfaRevision', 'rfaRev') + .innerJoin('rfaRev.statusCode', 'status') + .where('item.shopDrawingRevisionId IN (:...ids)', { + ids: shopDrawingRevisionIds, + }) + .andWhere('status.statusCode NOT IN (:...codes)', { + codes: ['CC', 'OBS'], + }) + .getMany(); + + if (conflictingItems.length > 0) { + throw new BadRequestException( + '[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA. ' + + 'A Shop Drawing Revision can only be referenced by one active RFA at a time.' + ); + } + } + + // Fetch real Organization Code for document numbering + const userOrg = await this.orgRepo.findOne({ + where: { id: userOrgId }, + select: ['organizationCode'], + }); + const orgCode = userOrg?.organizationCode ?? 'ORG'; + const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { - const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Org Service if needed - // [UPDATED] Generate Document Number with Discipline const docNumber = await this.numberingService.generateNextNumber({ projectId: internalProjectId, @@ -427,7 +458,7 @@ export class RfaService { // ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ... - async findAll(query: SearchRfaDto) { + async findAll(query: SearchRfaDto, _user?: User) { const { page = 1, limit = 20, @@ -481,14 +512,22 @@ export class RfaService { ); } + // RBAC: DFT documents are visible only to the originator org (spec §3.3.10) + if (_user?.primaryOrganizationId) { + queryBuilder.andWhere( + '(rfaRev.id IS NULL OR status.statusCode != :dftCode OR corr.originatorId = :userOrgId)', + { dftCode: 'DFT', userOrgId: _user.primaryOrganizationId } + ); + } + const [items, total] = await queryBuilder .orderBy('corr.createdAt', 'DESC') .skip(skip) .take(limit) .getManyAndCount(); - this.logger.log( - `[DEBUG] RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}` + this.logger.debug( + `RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}` ); // Map `revisions` property back to the expected payload for the frontend @@ -802,4 +841,83 @@ export class RfaService { await queryRunner.release(); } } + + /** + * Update a Draft RFA's revision fields (subject, body, remarks, description, dueDate). + * EC-RFA-002: Only allowed when current revision is in DFT status. + */ + async update(uuid: string, dto: UpdateRfaDto, _user: User) { + const rfa = await this.findOneByUuidRaw(uuid); + const corrRevisions = + (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; + const currentCorrRev = corrRevisions.find((r) => r.isCurrent); + if (!currentCorrRev || !currentCorrRev.rfaRevision) + throw new NotFoundException('Current revision not found'); + + const currentRfaRev = currentCorrRev.rfaRevision; + + if (currentRfaRev.statusCode.statusCode !== 'DFT') { + throw new BadRequestException( + 'Only DRAFT documents can be edited. Submit a new revision for non-draft documents.' + ); + } + + const updatedFields: Partial = {}; + if (dto.subject !== undefined) updatedFields.subject = dto.subject; + if (dto.body !== undefined) updatedFields.body = dto.body; + if (dto.remarks !== undefined) updatedFields.remarks = dto.remarks; + if (dto.description !== undefined) + updatedFields.description = dto.description; + if (dto.dueDate !== undefined) + updatedFields.dueDate = new Date(dto.dueDate); + + Object.assign(currentCorrRev, updatedFields); + + await this.corrRevRepo.save(currentCorrRev); + + if (dto.details !== undefined) { + currentRfaRev.details = dto.details; + await this.rfaRevisionRepo.save(currentRfaRev); + } + + return this.findOneByUuid(uuid); + } + + /** + * Cancel (soft-delete) a Draft RFA by setting its status to CC. + * EC-RFA-002: Only allowed when current revision is in DFT status. + */ + async cancel(uuid: string, user: User) { + const rfa = await this.findOneByUuidRaw(uuid); + const corrRevisions = + (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; + const currentCorrRev = corrRevisions.find((r) => r.isCurrent); + if (!currentCorrRev || !currentCorrRev.rfaRevision) + throw new NotFoundException('Current revision not found'); + + const currentRfaRev = currentCorrRev.rfaRevision; + + if (currentRfaRev.statusCode.statusCode !== 'DFT') { + throw new BadRequestException( + 'Only DRAFT documents can be cancelled. Contact an Org Admin to cancel submitted documents.' + ); + } + + const statusCC = await this.rfaStatusRepo.findOne({ + where: { statusCode: 'CC' }, + }); + if (!statusCC) + throw new InternalServerErrorException( + 'Status CC (Cancelled) not found in Master Data' + ); + + currentRfaRev.rfaStatusCodeId = statusCC.id; + await this.rfaRevisionRepo.save(currentRfaRev); + + this.logger.log( + `RFA ${rfa.correspondence?.correspondenceNumber} cancelled by user ${user.user_id}` + ); + + return { message: 'RFA cancelled successfully' }; + } } diff --git a/frontend/app/(dashboard)/rfas/[uuid]/edit/page.tsx b/frontend/app/(dashboard)/rfas/[uuid]/edit/page.tsx new file mode 100644 index 0000000..1359506 --- /dev/null +++ b/frontend/app/(dashboard)/rfas/[uuid]/edit/page.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useParams, useRouter, notFound } from 'next/navigation'; +import { useRFA, useUpdateRFA } from '@/hooks/use-rfa'; +import { Loader2 } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { UpdateRfaDto } from '@/types/dto/rfa/rfa.dto'; +import { useEffect } from 'react'; + +const editRfaSchema = z.object({ + subject: z.string().min(5, 'Subject must be at least 5 characters'), + description: z.string().optional(), + body: z.string().optional(), + remarks: z.string().optional(), + dueDate: z.string().optional(), +}); + +type EditRfaFormValues = z.infer; + +export default function RFAEditPage() { + const { uuid } = useParams(); + const router = useRouter(); + + if (!uuid) notFound(); + + const { data: rfa, isLoading, isError } = useRFA(String(uuid)); + const updateMutation = useUpdateRFA(); + + const currentRevision = + rfa?.revisions?.find((r) => r.isCurrent) ?? rfa?.revisions?.[0]; + + const form = useForm({ + resolver: zodResolver(editRfaSchema), + defaultValues: { + subject: '', + description: '', + body: '', + remarks: '', + dueDate: '', + }, + }); + + useEffect(() => { + if (currentRevision) { + form.reset({ + subject: currentRevision.subject ?? '', + description: currentRevision.description ?? '', + body: currentRevision.body ?? '', + remarks: currentRevision.remarks ?? '', + dueDate: currentRevision.dueDate + ? currentRevision.dueDate.slice(0, 10) + : '', + }); + } + }, [currentRevision, form]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !rfa) { + return
RFA not found.
; + } + + if (currentRevision?.statusCode?.statusCode !== 'DFT') { + return ( +
+ Only DRAFT RFAs can be edited.{' '} + +
+ ); + } + + const onSubmit = (values: EditRfaFormValues) => { + const dto: UpdateRfaDto = { + subject: values.subject, + description: values.description || undefined, + body: values.body || undefined, + remarks: values.remarks || undefined, + dueDate: values.dueDate || undefined, + }; + + updateMutation.mutate( + { uuid: String(uuid), data: dto }, + { + onSuccess: () => { + router.push(`/rfas/${String(uuid)}`); + }, + } + ); + }; + + return ( +
+
+

Edit RFA

+

+ {rfa.correspondence?.correspondenceNumber || 'Draft RFA'} +

+
+ + + + Revision Details + + +
+
+ + + {form.formState.errors.subject && ( +

+ {form.formState.errors.subject.message} +

+ )} +
+ +
+ +