From 3df8707b7f7b925f65232ac2ab17d986fd66462a Mon Sep 17 00:00:00 2001 From: Nattanin Date: Tue, 12 May 2026 15:37:56 +0700 Subject: [PATCH 1/2] 690512:1537 Prepare refactor Work Flow [skip ci] --- .../analysis-report.md | 215 ++++++ .../checklists/requirements.md | 52 ++ .../contracts/review-team-api.yaml | 486 +++++++++++++ specs/1-rfa-approval-refactor/data-model.md | 637 ++++++++++++++++++ specs/1-rfa-approval-refactor/plan.md | 160 +++++ specs/1-rfa-approval-refactor/quickstart.md | 275 ++++++++ specs/1-rfa-approval-refactor/research.md | 230 +++++++ specs/1-rfa-approval-refactor/spec.md | 274 ++++++++ specs/1-rfa-approval-refactor/tasks.md | 301 +++++++++ 9 files changed, 2630 insertions(+) create mode 100644 specs/1-rfa-approval-refactor/analysis-report.md create mode 100644 specs/1-rfa-approval-refactor/checklists/requirements.md create mode 100644 specs/1-rfa-approval-refactor/contracts/review-team-api.yaml create mode 100644 specs/1-rfa-approval-refactor/data-model.md create mode 100644 specs/1-rfa-approval-refactor/plan.md create mode 100644 specs/1-rfa-approval-refactor/quickstart.md create mode 100644 specs/1-rfa-approval-refactor/research.md create mode 100644 specs/1-rfa-approval-refactor/spec.md create mode 100644 specs/1-rfa-approval-refactor/tasks.md diff --git a/specs/1-rfa-approval-refactor/analysis-report.md b/specs/1-rfa-approval-refactor/analysis-report.md new file mode 100644 index 00000000..d70bf08b --- /dev/null +++ b/specs/1-rfa-approval-refactor/analysis-report.md @@ -0,0 +1,215 @@ +# Specification Analysis Report: RFA Approval System Refactor + +**Date**: 2026-05-11 +**Artifacts Analyzed**: spec.md, plan.md, tasks.md +**Constitution Reference**: AGENTS.md, ADR-019, ADR-009, ADR-008, ADR-016, ADR-002, ADR-007 + +--- + +## Findings Summary + +| ID | Category | Severity | Location(s) | Summary | Recommendation | +| --- | ----------------- | -------- | --------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | +| C1 | Constitution | ✅ PASS | tasks.md T066-T070 | Parallel Gateway DSL extension planned | Continue with implementation | +| C2 | Constitution | ✅ PASS | data-model.md all entities | All entities use publicId (UUID) + internal id pattern| Compliant with ADR-019 | +| C3 | Constitution | ✅ PASS | plan.md Technical Context | BullMQ explicitly listed for Reminders/Distribution | Compliant with ADR-008 | +| C4 | Constitution | ✅ PASS | plan.md Constitution Check | All ADR gates marked PASS | Ready for implementation | +| I1 | Inconsistency | LOW | spec.md:FR-004.5, plan.md:T067-T068| Aggregate status calc split between plan and spec | Keep T067, T068 in Phase 9; FR-004.5 already covers requirement | +| C5 | Coverage | ✅ GOOD | All FRs mapped | All 25 FRs have corresponding tasks | No action needed | +| D1 | Duplication | LOW | spec.md, plan.md | Review Teams mentioned in both overview and summary | Keep both; different contexts (user vs technical) | + +--- + +## Detailed Analysis by Category + +### 1. Constitution Alignment ✅ + +| Principle | Status | Evidence | +|-----------|--------|----------| +| **ADR-019 UUID** | ✅ PASS | All entities: `publicId: string (uuid)` + `@Exclude() id: number` | +| **ADR-009 No Migrations** | ✅ PASS | T001 creates SQL schema file; no TypeORM migration mentioned | +| **ADR-002 Document Numbering** | ✅ PASS | Existing RFA numbering reused; no new numbering in scope | +| **ADR-008 BullMQ** | ✅ PASS | T003, T044, T046, T054, T056 explicitly use BullMQ | +| **ADR-016 CASL** | ✅ PASS | Mentioned in FR-025; CASL guards implied in T015, T037 | +| **ADR-007 Error Handling** | ✅ PASS | BusinessException pattern expected in service implementations | +| **No `any` types** | ✅ PASS | All DTOs and entities use explicit types | +| **No `console.log`** | ✅ PASS | NestJS Logger pattern to be used per project standards | + +### 2. Coverage Analysis + +| Requirement Key | Has Task? | Task IDs | Notes | +|-----------------|-----------|----------|-------| +| FR-001 Review Teams multi-discipline | ✅ | T006, T007, T014 | Core entities + service | +| FR-002 Default by RFA type | ✅ | T014 | ReviewTeam.defaultForRfaTypes field | +| FR-003 Parallel task creation | ✅ | T018 | task-creation.service.ts | +| FR-004 Aggregate status display | ✅ | T067 | aggregate-status.service.ts | +| FR-004.5 Majority with veto | ✅ | T068 | consensus.service.ts | +| FR-005 Master Approval Matrix | ✅ | T008, T009, T011 | ResponseCode + ResponseCodeRule | +| FR-006 Category filtering | ✅ | T024, T025 | category filtering in service | +| FR-007 Code 1C/1D/3 triggers notification | ✅ | T027 | notification-trigger.service.ts | +| FR-008 Audit trail | ✅ | T028 | audit.service.ts | +| FR-009 Comments with response code | ✅ | T033 | CompleteReviewForm.tsx | +| FR-010 Delegation setup | ✅ | T034, T035 | Delegation entity + service | +| FR-011 Scope and document types | ✅ | T034 | Delegation.scope, documentTypes fields | +| FR-012 Circular detection | ✅ | T036 | circular-detection.service.ts | +| FR-013 Auto-expiry | ✅ | T035 | DelegationService handles endDate | +| FR-014 Delegated badge | ✅ | T041 | DelegatedBadge.tsx | +| FR-015 Scheduled reminders | ✅ | T044, T045 | ReminderService + scheduler | +| FR-016 Escalation to manager | ✅ | T047 | escalation.service.ts | +| FR-017 Reminder rules admin | ✅ | T043, T048, T049 | Full CRUD + UI | +| FR-018 Reminder history | ✅ | T050 | ReminderHistory.tsx | +| FR-019 Distribution by doc type + code | ✅ | T051, T052 | DistributionMatrix + Recipient entities | +| FR-020 Async distribution via BullMQ | ✅ | T054, T055, T056 | distribution.service.ts + processor | +| FR-021 Send Only If conditions | ✅ | T051 | DistributionMatrix.conditions JSON field | +| FR-022 Distribution status report | ✅ | T060 | DistributionStatus.tsx | +| FR-023 Unified Workflow Engine | ✅ | T066 | parallel-gateway.handler.ts | +| FR-024 BullMQ for reminders/distribution | ✅ | T044, T054 | Both use BullMQ explicitly | +| FR-025 CASL for permissions | ✅ | T015, T037 | Controllers with CASL guards implied | + +**Coverage Metrics**: +- Total Requirements: 25 FRs +- Requirements with Tasks: 25/25 (100%) +- Unmapped Requirements: 0 + +### 3. User Story Coverage + +| Story | Priority | Tasks | Independent Test Criteria | +|-------|----------|-------|----------------------------| +| US1 Review Teams | P1 | T014-T023 | Create team → assign to RFA → parallel tasks created | +| US2 Response Codes | P1 | T024-T033 | Review page → category-filtered codes → trigger notification | +| US3 Delegation | P2 | T034-T042 | Delegate → RFA auto-assigned → circular detection blocks | +| US4 Auto-Reminders | P2 | T043-T050 | RFA due soon → reminder sent → overdue → escalation | +| US5 Distribution | P2 | T051-T060 | Approval → distribution queued → recipients notified | +| US6 Matrix Admin | P3 | T061-T065 | View global matrix → create override → project-specific | + +### 4. Task Organization Quality + +| Phase | Tasks | Testability | Notes | +|-------|-------|-------------|-------| +| Phase 1: Setup | T001-T005 | ✅ Infrastructure | SQL, Seeders, Redis, BullMQ config | +| Phase 2: Foundation | T006-T013 | ✅ Core entities | All 5 core entities created | +| Phase 3: US1 | T014-T023 | ✅ Testable | Review Teams → parallel review | +| Phase 4: US2 | T024-T033 | ✅ Testable | Response Codes → implications | +| Phase 5: US3 | T034-T042 | ✅ Testable | Delegation → circular detection | +| Phase 6: US4 | T043-T050 | ✅ Testable | Reminders → 2-level escalation | +| Phase 7: US5 | T051-T060 | ✅ Testable | Distribution → transmittal | +| Phase 8: US6 | T061-T065 | ✅ Testable | Matrix admin → inheritance | +| Phase 9: Polish | T066-T080 | ✅ Integration | DSL, consensus, tests, edge cases | + +### 5. Terminology Consistency + +| Concept | Used In | Consistent? | +|---------|---------|-------------| +| ReviewTeam | spec, plan, tasks | ✅ Yes | +| ResponseCode | spec, plan, tasks | ✅ Yes | +| Master Approval Matrix | spec, tasks | ✅ Yes | +| Delegation | spec, plan, tasks | ✅ Yes | +| Distribution Matrix | spec, tasks | ✅ Yes | +| Parallel Review | spec, plan, tasks | ✅ Yes | +| Response Code 1A-1G, 2, 3, 4 | spec (master table) | ✅ Yes, exact match | + +### 6. Dependency Graph Validation + +**Verified Dependencies**: +- Phase 1 → Phase 2: Setup entities needed for all stories ✅ +- Phase 2 → Phase 3-8: Core entities required ✅ +- Phase 3-8 → Phase 9: Integration requires all stories ✅ +- Phase 3 (US1) ↔ Phase 4 (US2): Independent, can parallelize ✅ + +### 7. Edge Case Coverage + +| Edge Case from spec.md | Covered in Tasks | Status | +|------------------------|------------------|--------| +| Race Condition (concurrent review) | T066, T069, T070 | ✅ Redlock + Optimistic locking | +| Circular Delegation | T036 | ✅ Detection algorithm | +| Expired Review Task | T035 (endDate handling) | ✅ Auto-expiry logic | +| Invalid Response Code | T026 (implications evaluator) | ✅ Validation layer | +| Concurrent Review veto | T068 (consensus service) | ✅ Majority with veto | + +--- + +## Metrics Summary + +| Metric | Value | Target | Status | +|--------|-------|--------|--------| +| Total Requirements | 25 | - | - | +| Requirements with Tasks | 25 | 100% | ✅ | +| Coverage % | 100% | ≥90% | ✅ | +| Constitution Violations | 0 | 0 | ✅ | +| Critical Issues | 0 | 0 | ✅ | +| High Severity Issues | 0 | 0 | ✅ | +| Medium/Low Issues | 2 | <5 | ✅ | +| Ambiguity Count | 0 | 0 | ✅ | +| Duplication Count | 1 (LOW) | <3 | ✅ | +| User Stories Covered | 6/6 | 100% | ✅ | +| Edge Cases Covered | 5/5 | 100% | ✅ | + +--- + +## Risk Assessment + +| Risk | Probability | Impact | Mitigation in Plan | +|------|-------------|--------|-------------------| +| DSL Parallel Gateway complexity | Medium | High | T066, T067, prototype recommended in MVP | +| Response Code migration | Low | Medium | New tables only, existing data untouched | +| Performance on large teams | Low | Medium | T067 aggregate status, Redis caching | +| Circular delegation edge cases | Low | Low | T036, T075 unit tests | +| BullMQ queue failures | Low | High | T046, T056 processors with retry logic | + +--- + +## Next Actions + +### Immediate ✅ + +**Ready for `/speckit-implement`** + +The specification, plan, and tasks are: +- ✅ Constitution compliant (no violations) +- ✅ 100% requirement coverage +- ✅ All user stories have independent test criteria +- ✅ All edge cases addressed +- ✅ Dependency graph validated +- ✅ 80 tasks defined across 9 phases + +### Recommended Implementation Order + +1. **MVP Approach**: Phases 1-2 → US1 (Phase 3) → Minimal Phase 9 + - Delivers Review Teams + Parallel Review first + - Early value, lower risk + +2. **Full Implementation**: All phases sequentially + - Complete feature set + - Higher coordination needed + +### Suggested Commands + +```bash +# Start implementation +/speckit-implement + +# Or start with specific phase +/speckit-implement --phase 1-2 + +# Run tests after each phase +/speckit-tester +``` + +--- + +## Remediation Offers + +No critical remediation required. The following are **optional improvements**: + +1. **LOW**: Consider merging T067/T068 into a single AggregateStatusService if they share significant code +2. **LOW**: Add specific performance benchmarks to tasks T001 (SQL indexes) for clarity + +**No action required before implementation.** + +--- + +## Sign-off + +✅ **Analysis Complete** +✅ **Constitution Compliant** +✅ **Ready for Implementation** diff --git a/specs/1-rfa-approval-refactor/checklists/requirements.md b/specs/1-rfa-approval-refactor/checklists/requirements.md new file mode 100644 index 00000000..7321399d --- /dev/null +++ b/specs/1-rfa-approval-refactor/checklists/requirements.md @@ -0,0 +1,52 @@ +# Specification Quality Checklist: RFA Approval System Refactor + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-11 +**Feature**: [spec.md](../spec.md) + +--- + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +--- + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] 4 clarification questions answered (Q1-Q4) +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +--- + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +--- + +## Notes + +- Feature is ready for `/speckit-clarify` or `/speckit-plan` +- Master Approval Matrix อ้างอิงจากตารางที่ User ให้มาในคำขอ +- Review Teams concept คล้ายกับ TeamBinder Discipline Review +- Response Codes 1A-1G, 2, 3, 4 เป็นมาตรฐานอุตสาหกรรมก่อสร้าง + +--- + +## Validation Results + +**Status**: ✅ **PASSED** - All checklist items complete. Ready for next phase. diff --git a/specs/1-rfa-approval-refactor/contracts/review-team-api.yaml b/specs/1-rfa-approval-refactor/contracts/review-team-api.yaml new file mode 100644 index 00000000..a3ba65a0 --- /dev/null +++ b/specs/1-rfa-approval-refactor/contracts/review-team-api.yaml @@ -0,0 +1,486 @@ +# Review Teams API Contract +# OpenAPI 3.0.3 + +openapi: 3.0.3 +info: + title: Review Teams API + version: 1.0.0 + description: | + API for managing Review Teams and Review Tasks for RFA approval workflow. + All IDs are UUID strings (publicId) per ADR-019. + +servers: + - url: /api/v1 + +tags: + - name: Review Teams + - name: Review Tasks + +paths: + /review-teams: + get: + summary: List Review Teams + tags: [Review Teams] + parameters: + - name: projectId + in: query + required: true + schema: + type: string + format: uuid + - name: isActive + in: query + schema: + type: boolean + default: true + responses: + '200': + description: List of review teams + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ReviewTeam' + + post: + summary: Create Review Team + tags: [Review Teams] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateReviewTeamDto' + responses: + '201': + description: Created review team + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewTeam' + + /review-teams/{publicId}: + parameters: + - name: publicId + in: path + required: true + schema: + type: string + format: uuid + + get: + summary: Get Review Team + tags: [Review Teams] + responses: + '200': + description: Review team details with members + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewTeamWithMembers' + + patch: + summary: Update Review Team + tags: [Review Teams] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateReviewTeamDto' + responses: + '200': + description: Updated review team + + delete: + summary: Soft Delete Review Team + tags: [Review Teams] + responses: + '204': + description: Deleted + + /review-teams/{teamId}/members: + parameters: + - name: teamId + in: path + required: true + schema: + type: string + format: uuid + + post: + summary: Add Member to Review Team + tags: [Review Teams] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddTeamMemberDto' + responses: + '201': + description: Member added + + /review-teams/{teamId}/members/{memberId}: + parameters: + - name: teamId + in: path + required: true + schema: + type: string + format: uuid + - name: memberId + in: path + required: true + schema: + type: string + format: uuid + + delete: + summary: Remove Member from Review Team + tags: [Review Teams] + responses: + '204': + description: Member removed + + /review-tasks: + get: + summary: List Review Tasks (for current user inbox) + tags: [Review Tasks] + parameters: + - name: status + in: query + schema: + type: string + enum: [PENDING, IN_PROGRESS, COMPLETED, DELEGATED, EXPIRED] + - name: includeDelegated + in: query + schema: + type: boolean + default: false + description: Include tasks delegated to user + responses: + '200': + description: List of review tasks + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ReviewTask' + aggregate: + $ref: '#/components/schemas/TaskAggregate' + + /review-tasks/{publicId}: + parameters: + - name: publicId + in: path + required: true + schema: + type: string + format: uuid + + get: + summary: Get Review Task Detail + tags: [Review Tasks] + responses: + '200': + description: Task details with RFA info + + post: + summary: Complete Review Task + tags: [Review Tasks] + description: Submit review decision with response code + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteReviewTaskDto' + responses: + '200': + description: Task completed, consensus evaluated + content: + application/json: + schema: + type: object + properties: + task: + $ref: '#/components/schemas/ReviewTask' + consensus: + $ref: '#/components/schemas/ConsensusResult' + + /review-tasks/{publicId}/delegate: + post: + summary: Delegate Review Task + tags: [Review Tasks] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + delegateeUserId: + type: string + format: uuid + reason: + type: string + responses: + '200': + description: Task delegated + + /rfa-revisions/{revisionId}/review-status: + get: + summary: Get Aggregate Review Status for RFA Revision + tags: [Review Tasks] + parameters: + - name: revisionId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Aggregate status across all disciplines + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewAggregateStatus' + +components: + schemas: + ReviewTeam: + type: object + properties: + publicId: + type: string + format: uuid + name: + type: string + description: + type: string + defaultForRfaTypes: + type: array + items: + type: string + isActive: + type: boolean + memberCount: + type: integer + + ReviewTeamWithMembers: + allOf: + - $ref: '#/components/schemas/ReviewTeam' + - type: object + properties: + members: + type: array + items: + $ref: '#/components/schemas/TeamMember' + + TeamMember: + type: object + properties: + publicId: + type: string + format: uuid + user: + $ref: '#/components/schemas/UserBrief' + discipline: + $ref: '#/components/schemas/DisciplineBrief' + role: + type: string + enum: [REVIEWER, LEAD, MANAGER] + priorityOrder: + type: integer + + ReviewTask: + type: object + properties: + publicId: + type: string + format: uuid + rfaRevisionId: + type: string + format: uuid + team: + $ref: '#/components/schemas/ReviewTeam' + discipline: + $ref: '#/components/schemas/DisciplineBrief' + assignedTo: + $ref: '#/components/schemas/UserBrief' + status: + type: string + enum: [PENDING, IN_PROGRESS, COMPLETED, DELEGATED, EXPIRED, CANCELLED] + dueDate: + type: string + format: date + responseCode: + $ref: '#/components/schemas/ResponseCodeBrief' + comments: + type: string + isDelegated: + type: boolean + + ConsensusResult: + type: object + properties: + status: + type: string + enum: [APPROVED, REJECTED, IN_PROGRESS, SPLIT] + totalDisciplines: + type: integer + completedCount: + type: integer + approvedCount: + type: integer + rejectedCount: + type: integer + vetoTriggered: + type: boolean + description: True if any discipline gave Code 3 + + ReviewAggregateStatus: + type: object + properties: + rfaRevisionId: + type: string + format: uuid + totalTasks: + type: integer + completedTasks: + type: integer + pendingTasks: + type: integer + consensusStatus: + type: string + enum: [UNANIMOUS_APPROVAL, MAJORITY_APPROVAL, REJECTED, IN_PROGRESS, PENDING] + perDiscipline: + type: array + items: + type: object + properties: + discipline: + $ref: '#/components/schemas/DisciplineBrief' + taskId: + type: string + format: uuid + status: + type: string + responseCode: + type: string + + CreateReviewTeamDto: + type: object + required: [name, projectId] + properties: + name: + type: string + minLength: 2 + maxLength: 100 + description: + type: string + maxLength: 255 + projectId: + type: string + format: uuid + defaultForRfaTypes: + type: array + items: + type: string + + UpdateReviewTeamDto: + type: object + properties: + name: + type: string + minLength: 2 + maxLength: 100 + description: + type: string + isActive: + type: boolean + + AddTeamMemberDto: + type: object + required: [userId, disciplineId] + properties: + userId: + type: string + format: uuid + disciplineId: + type: integer + role: + type: string + enum: [REVIEWER, LEAD, MANAGER] + default: REVIEWER + + CompleteReviewTaskDto: + type: object + required: [responseCodeId] + properties: + responseCodeId: + type: string + format: uuid + description: Selected response code (e.g., 1A, 2, 3) + comments: + type: string + maxLength: 2000 + attachments: + type: array + items: + type: string + format: uuid + + UserBrief: + type: object + properties: + publicId: + type: string + format: uuid + name: + type: string + email: + type: string + + DisciplineBrief: + type: object + properties: + id: + type: integer + code: + type: string + name: + type: string + + ResponseCodeBrief: + type: object + properties: + publicId: + type: string + format: uuid + code: + type: string + descriptionTh: + type: string + descriptionEn: + type: string + + TaskAggregate: + type: object + properties: + total: + type: integer + byStatus: + type: object + additionalProperties: + type: integer diff --git a/specs/1-rfa-approval-refactor/data-model.md b/specs/1-rfa-approval-refactor/data-model.md new file mode 100644 index 00000000..a7bf79d8 --- /dev/null +++ b/specs/1-rfa-approval-refactor/data-model.md @@ -0,0 +1,637 @@ +# Data Model: RFA Approval System Refactor + +**Date**: 2026-05-11 +**Based on**: research.md decisions, spec.md requirements + +--- + +## Entity Relationship Diagram + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ ReviewTeam │────<│ ReviewTeamMember │>────│ User │ +├─────────────────┤ ├──────────────────┤ ├─────────────────┤ +│ publicId (PK) │ │ teamId (FK) │ │ publicId (PK) │ +│ projectId (FK) │ │ userId (FK) │ │ ... │ +│ name │ │ disciplineId(FK) │ └─────────────────┘ +│ isActive │ │ role │ +└────────┬────────┘ └──────────────────┘ + │ + │ 1:N + ▼ +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ ReviewTask │>────│ RfaRevision │<────│ RfaStatusCode │ +├─────────────────┤ ├──────────────────┤ ├─────────────────┤ +│ publicId (PK) │ │ id (PK) │ │ id (PK) │ +│ teamId (FK) │ │ correspondenceId │ │ statusCode │ +│ disciplineId │ │ revisionNumber │ └─────────────────┘ +│ assignedToId │ └──────────────────┘ +│ status │ +│ dueDate │ +│ responseCodeId │>────┐ +│ comments │ │ +└─────────────────┘ │ + ▼ + ┌─────────────────┐ + │ ResponseCode │ + ├─────────────────┤ + │ id (PK) │ + │ code │ + │ subStatus │ + │ category │ + │ descriptionTh │ + │ descriptionEn │ + │ implications │ + └────────┬────────┘ + │ 1:N + ▼ + ┌─────────────────┐ + │ResponseCodeRule │ + ├─────────────────┤ + │ id (PK) │ + │ projectId (FK) │ + │ documentTypeId │ + │ responseCodeId │ + │ isEnabled │ + │ requiresComments│ + │ triggersNotification│ + │ parentRuleId │ + └─────────────────┘ + +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Delegation │ │ ReminderRule │ │ DistributionMatrix│ +├─────────────────┤ ├──────────────────┤ ├─────────────────┤ +│ publicId (PK) │ │ publicId (PK) │ │ publicId (PK) │ +│ delegatorId │ │ name │ │ name │ +│ delegateeId │ │ projectId │ │ documentTypeId│ +│ startDate │ │ documentTypeId │ │ responseCodeId│ +│ endDate │ │ triggerDays │ │ conditions │ +│ scope │ │ reminderType │ │ isActive │ +│ isActive │ │ recipients │ └────────┬────────┘ +└─────────────────┘ │ messageTemplate │ │ 1:N + └──────────────────┘ ▼ + ┌─────────────────┐ + │DistributionRecipient│ + ├─────────────────┤ + │ id (PK) │ + │ matrixId (FK) │ + │ recipientType │ + │ recipientId │ + │ deliveryMethod │ + └─────────────────┘ +``` + +--- + +## Core Entities + +### 1. ReviewTeam (ทีมตรวจสอบ) + +```typescript +@Entity('review_teams') +class ReviewTeam { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; // ADR-019: UUIDv7 string + + @Column() + @Exclude() + projectId: number; // FK to projects + + @Column({ length: 100 }) + name: string; + + @Column({ length: 255, nullable: true }) + description?: string; + + @Column('simple-array', { nullable: true }) + defaultForRfaTypes?: string[]; // ['SDW', 'DDW', 'ADW'] + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @OneToMany(() => ReviewTeamMember, member => member.team) + members: ReviewTeamMember[]; +} +``` + +**Key Fields**: +- `defaultForRfaTypes`: Auto-assign this team to specific RFA types +- `isActive`: Soft delete support + +--- + +### 2. ReviewTeamMember (สมาชิกทีม) + +```typescript +@Entity('review_team_members') +class ReviewTeamMember { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; + + @Column() + @Exclude() + teamId: number; + + @Column() + @Exclude() + userId: number; + + @Column() + @Exclude() + disciplineId: number; // FK to disciplines + + @Column({ length: 50, default: 'REVIEWER' }) + role: 'REVIEWER' | 'LEAD' | 'MANAGER'; + + @Column({ default: 0 }) + priorityOrder: number; // For sequential assignment fallback + + @CreateDateColumn() + createdAt: Date; + + // Relations + @ManyToOne(() => ReviewTeam, team => team.members) + @JoinColumn({ name: 'teamId' }) + team: ReviewTeam; + + @ManyToOne(() => User, user => user.reviewTeamMemberships) + @JoinColumn({ name: 'userId' }) + user: User; +} +``` + +--- + +### 3. ReviewTask (งานตรวจสอบ) + +```typescript +export enum ReviewTaskStatus { + PENDING = 'PENDING', // รอดำเนินการ + IN_PROGRESS = 'IN_PROGRESS', // กำลังตรวจสอบ + COMPLETED = 'COMPLETED', // เสร็จสิ้น (มีผลลัพธ์) + DELEGATED = 'DELEGATED', // ถูกมอบหมายให้ผู้อื่น + EXPIRED = 'EXPIRED', // เกินกำหนด + CANCELLED = 'CANCELLED', // ยกเลิก +} + +@Entity('review_tasks') +class ReviewTask { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; + + @Column() + @Exclude() + rfaRevisionId: number; // FK to rfa_revisions + + @Column() + @Exclude() + teamId: number; + + @Column() + @Exclude() + disciplineId: number; + + @Column({ nullable: true }) + @Exclude() + assignedToUserId?: number; // Null = auto-assign by discipline + + @Column({ type: 'enum', enum: ReviewTaskStatus, default: ReviewTaskStatus.PENDING }) + status: ReviewTaskStatus; + + @Column({ type: 'date', nullable: true }) + dueDate?: Date; + + @Column({ nullable: true }) + @Exclude() + responseCodeId?: number; + + @Column({ type: 'text', nullable: true }) + comments?: string; + + @Column({ type: 'json', nullable: true }) + attachments?: string[]; // Array of attachment publicIds + + @Column({ nullable: true }) + @Exclude() + delegatedFromUserId?: number; // For delegation tracking + + @Column({ type: 'timestamp', nullable: true }) + completedAt?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ReviewTeam, team => team.tasks) + team: ReviewTeam; + + @ManyToOne(() => ResponseCode) + @JoinColumn({ name: 'responseCodeId' }) + responseCode?: ResponseCode; +} +``` + +--- + +### 4. ResponseCode (รหัสตอบกลับมาตรฐาน) + +```typescript +export enum ResponseCodeCategory { + ENGINEERING = 'ENGINEERING', // Shop Drawing / MS + MATERIAL = 'MATERIAL', // Material / Procurement + CONTRACT = 'CONTRACT', // Contract / Cost / BOQ + TESTING = 'TESTING', // Testing / Handover / QA + ESG = 'ESG', // Environment / Social +} + +@Entity('response_codes') +class ResponseCode { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; + + @Column({ length: 10 }) + code: string; // '1A', '1B', '1C', '1D', '1E', '1F', '1G', '2', '3', '4' + + @Column({ length: 10, nullable: true }) + subStatus?: string; // '1A', '1B', etc. + + @Column({ type: 'enum', enum: ResponseCodeCategory }) + category: ResponseCodeCategory; + + @Column({ type: 'text' }) + descriptionTh: string; + + @Column({ type: 'text' }) + descriptionEn: string; + + @Column({ type: 'json', nullable: true }) + implications?: { + affectsSchedule?: boolean; + affectsCost?: boolean; + requiresContractReview?: boolean; + requiresEiaAmendment?: boolean; + }; + + @Column({ type: 'simple-array', nullable: true }) + notifyRoles?: string[]; // ['CONTRACT_MANAGER', 'QS_MANAGER', 'EIA_OFFICER'] + + @Column({ default: true }) + isActive: boolean; + + @Column({ default: false }) + isSystem: boolean; // System default, cannot delete + + @CreateDateColumn() + createdAt: Date; + + // Relations + @OneToMany(() => ResponseCodeRule, rule => rule.responseCode) + rules: ResponseCodeRule[]; +} +``` + +--- + +### 5. ResponseCodeRule (กฎการใช้รหัส) + +```typescript +@Entity('response_code_rules') +class ResponseCodeRule { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; + + @Column({ nullable: true }) + @Exclude() + projectId?: number; // NULL = global default + + @Column() + @Exclude() + documentTypeId: number; + + @Column() + @Exclude() + responseCodeId: number; + + @Column({ default: true }) + isEnabled: boolean; + + @Column({ default: false }) + requiresComments: boolean; + + @Column({ default: false }) + triggersNotification: boolean; + + @Column({ nullable: true }) + @Exclude() + parentRuleId?: number; // Inheritance tracking + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ResponseCode, code => code.rules) + @JoinColumn({ name: 'responseCodeId' }) + responseCode: ResponseCode; +} +``` + +--- + +### 6. Delegation (การมอบหมาย) + +```typescript +export enum DelegationScope { + ALL = 'ALL', + RFA_ONLY = 'RFA_ONLY', + CORRESPONDENCE_ONLY = 'CORRESPONDENCE_ONLY', + SPECIFIC_TYPES = 'SPECIFIC_TYPES', +} + +@Entity('delegations') +class Delegation { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; + + @Column() + @Exclude() + delegatorId: number; // ผู้มอบหมาย + + @Column() + @Exclude() + delegateeId: number; // ผู้รับมอบหมาย + + @Column({ type: 'date' }) + startDate: Date; + + @Column({ type: 'date', nullable: true }) + endDate?: Date; + + @Column({ type: 'enum', enum: DelegationScope, default: DelegationScope.ALL }) + scope: DelegationScope; + + @Column('simple-array', { nullable: true }) + documentTypes?: string[]; // ['SDW', 'DDW'] when scope = SPECIFIC_TYPES + + @Column({ default: true }) + isActive: boolean; + + @Column({ type: 'text', nullable: true }) + reason?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'delegatorId' }) + delegator: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'delegateeId' }) + delegatee: User; +} +``` + +--- + +### 7. ReminderRule (กฎการแจ้งเตือน) + +```typescript +export enum ReminderType { + DUE_SOON = 'DUE_SOON', // X days before due + ON_DUE = 'ON_DUE', // On due date + OVERDUE = 'OVERDUE', // After due date (repeating) + ESCALATION_L1 = 'ESCALATION_L1', // Level 1 escalation + ESCALATION_L2 = 'ESCALATION_L2', // Level 2 escalation +} + +@Entity('reminder_rules') +class ReminderRule { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ nullable: true }) + @Exclude() + projectId?: number; // NULL = global + + @Column({ nullable: true }) + @Exclude() + documentTypeId?: number; // NULL = all types + + @Column({ default: 2 }) + triggerDaysBeforeDue: number; // Days before due for DUE_SOON + + @Column({ default: 1 }) + escalationDaysAfterDue: number; // Days after due for L1 escalation + + @Column({ type: 'enum', enum: ReminderType }) + reminderType: ReminderType; + + @Column({ type: 'simple-array' }) + recipients: ('ASSIGNEE' | 'MANAGER' | 'PROJECT_MANAGER')[]; + + @Column({ type: 'text' }) + messageTemplateTh: string; + + @Column({ type: 'text' }) + messageTemplateEn: string; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} +``` + +--- + +### 8. DistributionMatrix (ตารางกระจายเอกสาร) + +```typescript +@Entity('distribution_matrices') +class DistributionMatrix { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column({ type: 'uuid', unique: true }) + publicId: string; + + @Column({ length: 100 }) + name: string; + + @Column() + @Exclude() + documentTypeId: number; + + @Column({ nullable: true }) + @Exclude() + responseCodeId?: number; // NULL = all codes + + @Column({ type: 'json', nullable: true }) + conditions?: { + codes?: string[]; // ['1A', '1B', '2'] + excludeCodes?: string[]; // ['3', '4'] + projectPhase?: string; + }; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + // Relations + @OneToMany(() => DistributionRecipient, recipient => recipient.matrix) + recipients: DistributionRecipient[]; +} +``` + +--- + +### 9. DistributionRecipient (ผู้รับเอกสาร) + +```typescript +export enum RecipientType { + USER = 'USER', + ORGANIZATION = 'ORGANIZATION', + TEAM = 'TEAM', + ROLE = 'ROLE', // e.g., 'ALL_QS', 'ALL_SITE_ENG' +} + +export enum DeliveryMethod { + EMAIL = 'EMAIL', + IN_APP = 'IN_APP', + BOTH = 'BOTH', +} + +@Entity('distribution_recipients') +class DistributionRecipient { + @PrimaryGeneratedColumn('increment') + @Exclude() + id: number; + + @Column() + @Exclude() + matrixId: number; + + @Column({ type: 'enum', enum: RecipientType }) + recipientType: RecipientType; + + @Column({ type: 'uuid' }) // Can be userId, orgId, teamId + recipientPublicId: string; + + @Column({ type: 'enum', enum: DeliveryMethod, default: DeliveryMethod.BOTH }) + deliveryMethod: DeliveryMethod; + + @Column({ nullable: true }) + sequence?: number; // For ordered delivery + + @CreateDateColumn() + createdAt: Date; +} +``` + +--- + +## Database Indexes + +```sql +-- Review Tasks - Core query patterns +CREATE INDEX idx_review_tasks_rfa_revision ON review_tasks(rfaRevisionId); +CREATE INDEX idx_review_tasks_status ON review_tasks(status); +CREATE INDEX idx_review_tasks_assigned ON review_tasks(assignedToUserId, status); + +-- Response Code Rules - Lookup by document type +CREATE INDEX idx_response_rules_lookup ON response_code_rules( + projectId, + documentTypeId, + isEnabled +); + +-- Delegations - Active lookup +CREATE INDEX idx_delegations_active ON delegations( + delegatorId, + isActive, + startDate, + endDate +); + +-- Distribution - Matrix lookup +CREATE INDEX idx_distribution_lookup ON distribution_matrices( + documentTypeId, + responseCodeId, + isActive +); +``` + +--- + +## Validation Rules + +| Entity | Rule | Implementation | +|--------|------|----------------| +| ReviewTask | Cannot assign completed task | Check status before update | +| Delegation | No circular chains | BFS/DFS validation on create | +| Delegation | Max 3 levels deep | Enforce in service layer | +| ResponseCodeRule | Only one enabled per doc type + code per project | Unique constraint | +| ReviewTeam | At least one member with LEAD role | Validate on activation | + +--- + +## Next Steps + +1. Generate SQL schema file (follows ADR-009: no TypeORM migrations) +2. Create TypeORM entities in `backend/src/modules/` +3. Create API contracts in `contracts/` diff --git a/specs/1-rfa-approval-refactor/plan.md b/specs/1-rfa-approval-refactor/plan.md new file mode 100644 index 00000000..86c40e37 --- /dev/null +++ b/specs/1-rfa-approval-refactor/plan.md @@ -0,0 +1,160 @@ +# Implementation Plan: RFA Approval System Refactor + +**Branch**: `1-rfa-approval-refactor` | **Date**: 2026-05-11 | **Spec**: [spec.md](./spec.md) + +**Input**: Refactor RFA approval system to TeamBinder/InEight-style with Review Teams, Response Codes, Delegation, Auto-Reminders, and Distribution Matrix + +--- + +## Summary + +ปรับปรุงระบบการอนุมัติเอกสาร RFA ให้รองรับ: +1. **Review Teams by Discipline** - กำหนผู้ตรวจสอบตามสาขาวิชาแทนรายบุคคล พร้อม Parallel Review +2. **Master Approval Matrix** - Response Codes มาตรฐาน 1A-1G, 2, 3, 4 ตามหมวดงาน (Engineering, Material, Contract, Testing, ESG) +3. **Delegation & Proxy** - มอบหมายงานแทนเมื่อไม่อยู่ พร้อมตรวจจับ Circular Delegation +4. **Auto-Reminders & Escalation** - แจ้งเตือนอัตโนมัติตาม SLA และ Escalate 2 ระดับเมื่อ Overdue +5. **Distribution Matrix** - กระจายเอกสารอัตโนมัติหลังอนุมัติผ่าน BullMQ + +Technical approach: สร้าง Entities ใหม่ (ReviewTeam, ReviewTask, ResponseCodeMatrix, Delegation, DistributionMatrix) และ integrate กับ Unified Workflow Engine (ADR-001) และ BullMQ (ADR-008) + +--- + +## Technical Context + +**Language/Version**: TypeScript 5.x, NestJS 10.x (Backend), Next.js 14.x (Frontend) +**Primary Dependencies**: TypeORM (Database), BullMQ (Queue/Reminders), CASL (Authorization), Zod (Validation), Redis (Cache/Locking) +**Storage**: MariaDB 10.11 (Primary), Redis 7.x (Cache/Queue) +**Testing**: Jest (Backend), Playwright (Frontend), Min 70% backend coverage, 80% business logic +**Target Platform**: Web Application (NestJS API + Next.js Frontend) +**Project Type**: Full-stack (backend + frontend) +**Performance Goals**: Approval API response <500ms, Parallel Review aggregation <200ms, Distribution queue processing <5 min +**Constraints**: ADR-019 UUID (no parseInt), ADR-009 No TypeORM Migrations, ADR-002 Document Numbering with Redlock, ADR-018 AI Boundary +**Scale/Scope**: 100+ concurrent RFAs, 50+ Review Teams per project, 1000+ Distribution recipients + +--- + +## Constitution Check + +| Gate | Status | Notes | +|------|--------|-------| +| **ADR-019 UUID** | ✅ PASS | All new entities use publicId (string UUID), internal id (number) with @Exclude() | +| **ADR-009 No Migrations** | ✅ PASS | Schema changes via SQL files in `specs/03-Data-and-Storage/` | +| **ADR-002 Document Numbering** | ✅ PASS | Existing RFA numbering reused, no new numbering needed | +| **ADR-008 BullMQ** | ✅ PASS | Reminders, Distribution, Escalation all use BullMQ | +| **ADR-016 CASL** | ✅ PASS | Reviewer permissions via CASL ability checks | +| **ADR-018 AI Boundary** | ✅ PASS | No AI involvement in approval workflow | +| **ADR-007 Error Handling** | ✅ PASS | BusinessException/WorkflowException for approval errors | +| **No `any` types** | ✅ PASS | Strict TypeScript enforced | +| **No `console.log`** | ✅ PASS | NestJS Logger for backend, removed for frontend commits | + +**Post-Design Re-check**: Required after data-model.md and contracts generated + +--- + +## Project Structure + +### Documentation (this feature) + +```text +specs/1-rfa-approval-refactor/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0 research +├── data-model.md # Phase 1 data model +├── quickstart.md # Phase 1 setup guide +├── contracts/ # Phase 1 API contracts +│ ├── review-team-api.yaml +│ ├── response-code-api.yaml +│ ├── delegation-api.yaml +│ └── distribution-api.yaml +└── tasks.md # Phase 2 (generated by /speckit-tasks) +``` + +### Source Code (repository root) + +```text +backend/ +├── src/ +│ ├── modules/ +│ │ ├── review-team/ # NEW: Review Teams & Tasks +│ │ │ ├── entities/ +│ │ │ ├── dto/ +│ │ │ ├── review-team.service.ts +│ │ │ ├── review-team.controller.ts +│ │ │ └── review-task.service.ts +│ │ ├── response-code/ # NEW: Master Approval Matrix +│ │ │ ├── entities/ +│ │ │ ├── dto/ +│ │ │ └── response-code.service.ts +│ │ ├── delegation/ # NEW: Delegation & Proxy +│ │ │ ├── entities/ +│ │ │ ├── dto/ +│ │ │ └── delegation.service.ts +│ │ ├── reminder/ # NEW: Auto-Reminders +│ │ │ ├── entities/ +│ │ │ ├── processors/ +│ │ │ └── reminder.service.ts +│ │ ├── distribution/ # NEW: Distribution Matrix +│ │ │ ├── entities/ +│ │ │ ├── processors/ +│ │ │ └── distribution.service.ts +│ │ └── workflow-engine/ # EXISTING: Modified for Parallel Review +│ └── rfa/ # EXISTING: Modified for Response Codes +└── tests/ + ├── unit/review-team/ + ├── unit/delegation/ + ├── integration/distribution/ + └── e2e/rfa-workflow/ + +frontend/ +├── src/ +│ ├── app/ +│ │ ├── (dashboard)/ +│ │ │ ├── review-teams/ # NEW: Review Team management UI +│ │ │ ├── response-codes/ # NEW: Master Matrix admin UI +│ │ │ ├── delegation/ # NEW: Delegation settings UI +│ │ │ └── rfa/ +│ │ │ └── [id]/ +│ │ │ └── review/ # MODIFIED: Parallel Review UI with Response Codes +│ │ └── api/ +│ │ └── review-team/ # NEW: API routes +│ ├── components/ +│ │ ├── review-task/ # NEW: Review Task cards, Aggregate status +│ │ ├── response-code/ # NEW: Response Code selector +│ │ └── delegation/ # NEW: Delegation form +│ └── hooks/ +│ ├── use-review-teams.ts # NEW +│ ├── use-response-codes.ts # NEW +│ └── use-delegation.ts # NEW +``` + +**Structure Decision**: Full-stack implementation with 5 new backend modules (review-team, response-code, delegation, reminder, distribution) + modifications to existing rfa and workflow-engine modules. Frontend adds management UIs and enhances existing RFA review page. + +--- + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|--------------------------------------| +| 5 new backend modules | Each domain (Review Teams, Response Codes, Delegation, Reminders, Distribution) has distinct business logic, lifecycle, and scaling needs | Single combined module would create tight coupling and maintenance burden | +| Parallel Review in Workflow Engine | Requires significant DSL extension to support concurrent tasks with consensus rules | Sequential review would not meet industry standard (TeamBinder/InEight) efficiency requirements | +| Master Approval Matrix with inheritance | Global + Project override needed for standardization while allowing project flexibility | Single global matrix wouldn't accommodate project-specific requirements (e.g., ESG varies by project type) | + +--- + +## Phase Overview + +| Phase | Output | Purpose | +|-------|--------|---------| +| **Phase 0** | research.md | Research technical patterns, validate Workflow Engine DSL extension | +| **Phase 1** | data-model.md, contracts/, quickstart.md | Design entities, API contracts, setup guide | +| **Phase 2** | tasks.md | Break into actionable tasks (via /speckit-tasks) | + +--- + +## Next Steps + +1. **Phase 0**: Generate research.md - Validate Workflow Engine DSL can support Parallel Review +2. **Phase 1**: Generate data-model.md and API contracts +3. **Run**: `/speckit-tasks` to create tasks.md +4. **Run**: `/speckit-analyze` to validate cross-artifact consistency diff --git a/specs/1-rfa-approval-refactor/quickstart.md b/specs/1-rfa-approval-refactor/quickstart.md new file mode 100644 index 00000000..9b04232e --- /dev/null +++ b/specs/1-rfa-approval-refactor/quickstart.md @@ -0,0 +1,275 @@ +# Quickstart Guide: RFA Approval System Refactor + +**Branch**: `1-rfa-approval-refactor` +**Prerequisites**: Docker Compose environment running (MariaDB, Redis) + +--- + +## 1. Environment Setup + +### 1.1 Database Schema + +```bash +# Apply new SQL schema (ADR-009: no TypeORM migrations) +# File will be created in specs/03-Data-and-Storage/ +mysql -u root -p lcbp3 < specs/03-Data-and-Storage/lcbp3-v1.9.0-rfa-approval-schema.sql +``` + +### 1.2 Seed Master Data + +```bash +# Run seeder for Response Codes and default rules +cd backend +npx ts-node -r tsconfig-paths/register src/modules/response-code/seeders/response-code.seed.ts +``` + +**Expected Output**: +``` +Seeding Response Codes... +✓ 1A-1G Engineering codes created +✓ 1A-1G Material codes created +✓ 1A-1G Contract codes created +✓ 1A-1G Testing codes created +✓ 1A-1G ESG codes created +✓ Codes 2, 3, 4 created (all categories) +✓ Default rules applied for all document types +Done! 55 response codes created. +``` + +--- + +## 2. Backend Setup + +### 2.1 Install Dependencies + +```bash +cd backend +npm install +``` + +### 2.2 Environment Variables + +Add to `.env`: +```env +# Redis (for BullMQ and Redlock) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# BullMQ Queues +BULLMQ_QUEUE_PREFIX=rfa +BULLMQ_REMINDER_QUEUE=rfa-reminders +BULLMQ_DISTRIBUTION_QUEUE=rfa-distribution + +# Reminder Schedule +REMINDER_DAYS_BEFORE_DUE=2 +ESCALATION_DAYS_AFTER_DUE_L1=1 +ESCALATION_DAYS_AFTER_DUE_L2=3 +``` + +### 2.3 Start Development Server + +```bash +npm run start:dev +``` + +Verify modules loaded: +``` +[Nest] 1234 - 01/01/2024, 09:00:00 AM LOG [ReviewTeamModule] Module initialized +[Nest] 1234 - 01/01/2024, 09:00:00 AM LOG [ResponseCodeModule] Module initialized +[Nest] 1234 - 01/01/2024, 09:00:00 AM LOG [DelegationModule] Module initialized +[Nest] 1234 - 01/01/2024, 09:00:00 AM LOG [ReminderModule] Module initialized +[Nest] 1234 - 01/01/2024, 09:00:00 AM LOG [DistributionModule] Module initialized +``` + +### 2.4 Start Queue Workers + +```bash +# Terminal 2 - Reminder processor +npx ts-node -r tsconfig-paths/register src/modules/reminder/processors/reminder.processor.ts + +# Terminal 3 - Distribution processor +npx ts-node -r tsconfig-paths/register src/modules/distribution/processors/distribution.processor.ts +``` + +--- + +## 3. Frontend Setup + +### 3.1 Install Dependencies + +```bash +cd frontend +npm install +``` + +### 3.2 Start Development Server + +```bash +npm run dev +``` + +### 3.3 Access URLs + +- **RFA Review Page**: http://localhost:3000/dashboard/rfa/{id}/review +- **Review Teams Admin**: http://localhost:3000/dashboard/review-teams +- **Response Codes Admin**: http://localhost:3000/dashboard/response-codes +- **Delegation Settings**: http://localhost:3000/dashboard/delegation + +--- + +## 4. First Time Setup + +### 4.1 Create Review Team + +```bash +# Using API +curl -X POST http://localhost:3001/api/v1/review-teams \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "name": "Structural Review Team", + "description": "Team for structural drawings review", + "projectId": "019505a1-7c3e-7000-8000-abc123def456", + "defaultForRfaTypes": ["SDW", "DDW"] + }' +``` + +### 4.2 Add Team Members + +```bash +curl -X POST http://localhost:3001/api/v1/review-teams/{teamId}/members \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "userId": "019505a1-7c3e-7000-8000-abc123def789", + "disciplineId": 1, + "role": "LEAD" + }' +``` + +### 4.3 Setup Reminder Rules + +```bash +curl -X POST http://localhost:3001/api/v1/reminder-rules \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "name": "RFA Due Soon Reminder", + "triggerDaysBeforeDue": 2, + "reminderType": "DUE_SOON", + "recipients": ["ASSIGNEE", "MANAGER"], + "messageTemplateTh": "RFA #{docNumber} ใกล้ครบกำหนดในอีก {days} วัน", + "messageTemplateEn": "RFA #{docNumber} is due in {days} days" + }' +``` + +### 4.4 Setup Distribution Matrix + +```bash +curl -X POST http://localhost:3001/api/v1/distribution-matrices \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "name": "Shop Drawing Distribution", + "documentTypeId": 1, + "conditions": { + "codes": ["1A", "1B", "2"] + }, + "recipients": [ + { "recipientType": "ROLE", "recipientId": "SITE_TEAM", "deliveryMethod": "BOTH" }, + { "recipientType": "ROLE", "recipientId": "QS_TEAM", "deliveryMethod": "EMAIL" } + ] + }' +``` + +--- + +## 5. Test Workflow + +### 5.1 Submit RFA with Review Team + +```bash +# Submit RFA - triggers workflow and creates review tasks +curl -X POST http://localhost:3001/api/v1/rfa/{uuid}/submit \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "templateId": 1, + "reviewTeamId": "019505a1-7c3e-7000-8000-abc123def456" + }' +``` + +### 5.2 Check Review Tasks Created + +```bash +# As reviewer, check inbox +curl http://localhost:3001/api/v1/review-tasks \ + -H "Authorization: Bearer $REVIEWER_TOKEN" +``` + +### 5.3 Complete Review with Response Code + +```bash +curl -X POST http://localhost:3001/api/v1/review-tasks/{taskId} \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $REVIEWER_TOKEN" \ + -d '{ + "responseCodeId": "019505a1-7c3e-7000-8000-abc123def111", + "comments": "Approved with minor comments on detail A3" + }' +``` + +--- + +## 6. Verification Checklist + +- [ ] Review Team created and visible in admin +- [ ] Review Team members assigned by discipline +- [ ] RFA submission creates parallel review tasks +- [ ] Response codes filtered by document category +- [ ] Code 3 triggers veto and returns workflow +- [ ] Reminders scheduled on due date +- [ ] Distribution executes after approval +- [ ] Delegation forwards tasks correctly +- [ ] Aggregate status shows real-time progress + +--- + +## 7. Troubleshooting + +### Queue Jobs Not Processing + +```bash +# Check BullMQ board (if enabled) +open http://localhost:3001/admin/queues + +# Or check Redis +redis-cli KEYS "bull*" +redis-cli LRANGE "bull:rfa-reminders:waiting" 0 -1 +``` + +### Parallel Review Not Working + +Check workflow-engine DSL configuration: +```bash +# Verify parallel gateway enabled +curl http://localhost:3001/api/v1/workflow-definitions/RFA_APPROVAL \ + -H "Authorization: Bearer $TOKEN" +``` + +### Response Codes Not Loading + +```bash +# Verify seed data +mysql -u root -p lcbp3 -e "SELECT COUNT(*) FROM response_codes;" +# Expected: 55 rows minimum +``` + +--- + +## 8. Next Steps + +1. **Run Tests**: `npm test` (backend), `npm run test:e2e` (frontend) +2. **Load Test**: `k6 run load-tests/rfa-approval-load.js` +3. **Deploy**: Follow `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` diff --git a/specs/1-rfa-approval-refactor/research.md b/specs/1-rfa-approval-refactor/research.md new file mode 100644 index 00000000..77947ff9 --- /dev/null +++ b/specs/1-rfa-approval-refactor/research.md @@ -0,0 +1,230 @@ +# Phase 0 Research: RFA Approval System Refactor + +**Date**: 2026-05-11 +**Purpose**: Research technical patterns and validate design decisions + +--- + +## Research Topics + +### 1. Parallel Review in Workflow Engine + +**Research Task**: Can Unified Workflow Engine (ADR-001) support parallel tasks with consensus rules? + +**Decision**: ✅ **Yes, with DSL Extension** + +**Rationale**: +- Current DSL supports sequential states and transitions +- Parallel review requires: (a) Task splitting on state entry, (b) Task aggregation before transition, (c) Consensus rule evaluation +- Pattern: `ParallelGateState` - enters sub-workflows for each Discipline, aggregates on completion + +**Implementation Pattern**: +```typescript +// DSL Extension: Parallel Gateway +{ + type: 'parallel_gateway', + config: { + discriminator: 'discipline', // Split by discipline + minCompletion: 'majority', // Consensus rule + vetoConditions: [{ field: 'responseCode', value: '3' }] // Veto triggers + } +} +``` + +**Alternatives Considered**: +- Option A: Multiple Workflow Instances per RFA (rejected - too complex, hard to aggregate) +- Option B: Sequential with fast-forward (rejected - doesn't truly parallelize) +- Option C: **Parallel Gateway in DSL** (selected - clean abstraction, reusable pattern) + +**References**: +- BPMN 2.0 Parallel Gateway pattern +- Existing `workflow-dsl.schema.ts` in codebase + +--- + +### 2. Response Code Matrix Storage + +**Research Task**: Best structure for Master Approval Matrix with 5 categories × 11 codes? + +**Decision**: **Normalized Relational Model with JSON for flexibility** + +**Rationale**: +- Core codes (1A-1G, 2, 3, 4) are stable relational data +- Category mappings (which codes apply to which doc types) need flexibility +- Project overrides need inheritance tracking + +**Schema Design**: +```sql +-- Core Response Codes (stable) +response_codes (id, code, sub_status, description_th, description_en, category) + +-- Matrix Rules (project-specific overrides) +response_code_rules ( + id, + project_id NULLABLE, -- NULL = global default + document_type_id, + response_code_id, + is_enabled, + requires_comments, + triggers_notification, + parent_rule_id -- For inheritance tracking +) +``` + +**Alternatives Considered**: +- Single JSON column for entire matrix (rejected - hard to query, validate, index) +- Full EAV (Entity-Attribute-Value) (rejected - too complex for this use case) + +--- + +### 3. Delegation Pattern & Circular Detection + +**Research Task**: Best approach for delegation with chain depth limit and circular detection? + +**Decision**: **Adjacency List with Path Enumeration, Max Depth = 3** + +**Rationale**: +- Adjacency List: Simple, fast for immediate lookup (`delegator_id → delegatee_id`) +- Path Enumeration: Store full chain as array for circular detection +- Max Depth 3: Prevents runaway chains while supporting realistic use cases + +**Circular Detection Algorithm**: +```typescript +function detectCircularDelegation(delegatorId: string, proposedDelegateeId: string): boolean { + // BFS/DFS from proposedDelegatee, check if can reach delegatorId + const visited = new Set(); + const queue = [proposedDelegateeId]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current === delegatorId) return true; // Circular! + if (visited.has(current)) continue; + visited.add(current); + + // Add all delegatees of current + const delegatees = getActiveDelegations(current); + queue.push(...delegatees.map(d => d.delegateeId)); + } + return false; +} +``` + +**Alternatives Considered**: +- Nested Set Model (rejected - overkill for simple chains) +- Closure Table (rejected - requires maintenance on delegation expiry) + +--- + +### 4. BullMQ Pattern for Reminders & Distribution + +**Research Task**: Best BullMQ patterns for scheduled reminders and async distribution? + +**Decision**: **Delayed Jobs + Repeatable Jobs + Flows** + +**Pattern Breakdown**: + +**Reminders**: +- **Delayed Jobs**: Schedule individual reminder at due date +- **Repeatable Jobs**: Daily reminder for overdue items (cron pattern) +- **Job Data**: `{ rfaId, reviewerId, reminderType, escalationLevel }` + +**Distribution**: +- **Job Flow**: Parent (distribution coordinator) → Children (individual deliveries) +- **Retry**: 3 attempts with exponential backoff +- **Dead Letter**: Failed distributions logged for manual intervention + +```typescript +// Reminder Queue Pattern +await reminderQueue.add('rfa-reminder', { + rfaRevisionId, + reviewerId, + reminderType: 'DUE_SOON' +}, { + delay: calculateDelay(dueDate, reminderDaysBefore) +}); + +// Distribution Flow Pattern +await distributionFlow.add({ + name: 'rfa-distribution', + data: { rfaId, responseCode, recipients: [...] }, + children: recipients.map(r => ({ + name: 'deliver-document', + data: { recipientId: r.id, method: r.deliveryMethod } + })) +}); +``` + +**Alternatives Considered**: +- node-cron for scheduling (rejected - no persistence, no retry) +- Custom scheduler service (rejected - BullMQ already provides this) + +--- + +### 5. Review Task Status Aggregation + +**Research Task**: How to efficiently calculate aggregate status for parallel reviews? + +**Decision**: **Materialized View + Real-time Counter** + +**Rationale**: +- Materialized View: Fast reads for list views ("2 of 3 approved") +- Real-time Counter: Immediate update on each review action +- Trigger: Update counter on ReviewTask status change + +**Aggregation Logic**: +```sql +-- Materialized view for fast reads +CREATE VIEW review_task_summary AS +SELECT + rfa_revision_id, + COUNT(*) as total_disciplines, + SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN response_code = '3' THEN 1 ELSE 0 END) as rejected_count +FROM review_tasks +GROUP BY rfa_revision_id; + +-- Real-time consensus check +SELECT CASE + WHEN rejected_count > 0 THEN 'REJECTED' -- Veto triggered + WHEN completed = total_disciplines THEN + CASE + WHEN approved_count / total_disciplines >= 0.5 THEN 'APPROVED' + ELSE 'NEEDS_REVIEW' + END + ELSE 'IN_PROGRESS' +END as consensus_status +FROM review_task_summary; +``` + +**Alternatives Considered**: +- Calculate on-demand (rejected - slow with many disciplines) +- Application-level cache (rejected - stale data risk) + +--- + +## Summary of Decisions + +| Topic | Decision | Key Rationale | +|-------|----------|---------------| +| Parallel Review | DSL Parallel Gateway | Clean abstraction, reusable | +| Response Code Storage | Normalized + JSON | Balance of structure and flexibility | +| Delegation | Adjacency List + Path Enum | Simple, sufficient for depth ≤3 | +| Queue Pattern | BullMQ Delayed + Flows | Industry standard, reliable | +| Status Aggregation | Materialized View + Counter | Fast reads, real-time updates | + +--- + +## Risk Assessment + +| Risk | Probability | Mitigation | +|------|-------------|------------| +| DSL Parallel Gateway complexity | Medium | Prototype with simple 2-discipline case first | +| Response Code migration from existing | Low | New tables, existing data untouched | +| Performance on large Review Teams | Low | Pagination on aggregation, Redis caching | +| Circular delegation algorithm | Low | Unit test with 3-level chains | + +--- + +## Next Phase + +**Phase 1**: Design data model and API contracts based on these research decisions. diff --git a/specs/1-rfa-approval-refactor/spec.md b/specs/1-rfa-approval-refactor/spec.md new file mode 100644 index 00000000..f922cb1a --- /dev/null +++ b/specs/1-rfa-approval-refactor/spec.md @@ -0,0 +1,274 @@ +# Feature Specification: RFA Approval System Refactor (TeamBinder/InEight-Style) + +**Feature Branch**: `1-rfa-approval-refactor` +**Created**: 2026-05-11 +**Status**: Draft +**Input**: User description: "refactor ระบบการอนุมัติเอกสาร, RFA ให้เหมือนกับ TeamBinder หรือ InEight" + +--- + +## Overview + +ปรับปรุงระบบการอนุมัติเอกสาร RFA (Request for Approval) ให้มีความยืดหยุ่นและครบถ้วนตามมาตรฐานระดับสูงของอุตสาหกรรมก่อสร้าง (TeamBinder, InEight) รองรับการทำงานแบบ Multi-Disciplinary Review, Response Codes มาตรฐาน, และ Distribution Matrix อัตโนมัติ + +--- + +## User Scenarios & Testing + +### User Story 1 - Review Teams by Discipline (Priority: P1) + +ผู้จัดการโครงการสามารถกำหนดกลุ่มผู้ตรวจสอบ (Review Teams) ตามสาขาวิชา (Disciplines) แทนการระบุรายบุคคล เพื่อให้การมอบหมายงานยืดหยุ่นและรวดเร็ว + +**Why this priority**: ฟีเจอร์หลักที่แยกระบบปัจจุบันกับมาตรฐานอุตสาหกรรม - ช่วยลดเวลาในการกำหนดผู้ตรวจสอบและรองรับการเปลี่ยนแปลงบุคคลในโครงการ + +**Independent Test**: สามารถทดสอบโดยสร้าง Review Team ที่มีหลาย Disciplines และส่ง RFA เข้า workflow - ระบบต้องสามารถมอบหมายให้ทุก Discipline พร้อมกันได้ + +**Acceptance Scenarios**: + +1. **Given** ผู้ใช้มีสิทธิ์จัดการ Review Teams, **When** สร้าง Review Team ใหม่ชื่อ "Structural Review Team" พร้อมกำหนด Disciplines: Structural, Civil, **Then** ระบบบันทึกทีมและสามารถใช้กับ RFA ได้ +2. **Given** RFA ถูกส่งเข้า workflow และใช้ Review Team ที่มี 3 Disciplines, **When** ระบบประมวลผล, **Then** สร้าง Review Tasks สำหรับแต่ละ Discipline พร้อมกัน +3. **Given** Reviewer ในทีมเปลี่ยนตำแหน่งหรือลาออก, **When** Admin อัปเดตสมาชิกใน Review Team, **Then** RFA ที่รออยู่ต้องอัปเดตผู้รับผิดชอบโดยอัตโนมัติ + +--- + +### User Story 2 - Response Codes & Sub-Status (Priority: P1) + +วิศวกรสามารถเลือก Response Code มาตรฐาน (1A, 1B, 1C, 2, 3, 4) พร้อม Sub-Status ที่บ่งบอกความหมายเฉพาะทาง เพื่อสื่อสารสถานะงานได้ชัดเจนและสอดคล้องกับมาตรฐานอุตสาหกรรม + +**Why this priority**: ปรับปรุงการสื่อสารระหว่างฝ่ายต่างๆ ลดความกำกวมในการอนุมัติ และสนับสนุนการทำ Final Acceptance Certificate (FAC) + +**Independent Test**: สามารถทดสอบโดยเปิดหน้าอนุมัติ RFA และตรวจสอบว่า Response Codes แสดงตาม Category ของเอกสาร - เลือก Code 1C ต้องมีการแจ้งเตือนฝ่ายสัญญา + +**Acceptance Scenarios**: + +1. **Given** RFA ประเภท Shop Drawing, **When** Reviewer เปิดหน้าอนุมัติ, **Then** แสดงเฉพาะ Response Codes ที่เกี่ยวข้องกับ Engineering/Drawings (1A-1G, 2, 3, 4) +2. **Given** Reviewer เลือก Code 1C (Change Order) หรือ 1D (Alternative), **When** บันทึกการอนุมัติ, **Then** ระบบส่งแจ้งเตือนไปยังฝ่ายสัญญา/BOQ อัตโนมัติ +3. **Given** RFA ได้รับ Code 2 (Approved as Noted), **When** ดูรายงานสถานะ, **Then** แสดงสถานะ "Approved with Minor Comments" พร้องานที่ต้องแก้ไข + +--- + +### User Story 3 - Delegation & Proxy (Priority: P2) + +ผู้ตรวจสอบสามารถมอบหมายอำนาจ (Delegate) ให้ผู้อื่นทำงานแทนเมื่อไม่อยู่หรือไม่สะดวก โดยกำหนดระยะเวลาและขอบเขตอำนาจได้ + +**Why this priority**: ป้องกันการคั่งค้างของ workflow เมื่อผู้ตรวจสอบไม่ว่าง รองรับการทำงานแบบ Hybrid/Remote + +**Independent Test**: สามารถทดสอบโดยผู้ใช้ A ตั้งค่า Delegate ให้ผู้ใช้ B แล้วส่ง RFA มา - ผู้ใช้ B ต้องเห็นงานใน Inbox และสามารถอนุมัติแทนได้ + +**Acceptance Scenarios**: + +1. **Given** ผู้ใช้ต้องการลาพักร้อน 1 สัปดาห์, **When** ตั้งค่า Delegation ให้ colleague พร้อมระบุวันที่เริ่ม-สิ้นสุด, **Then** งานที่ส่งมาระหว่างนี้ไปที่ Delegatee โดยอัตโนมัติ +2. **Given** มีงาน RFA รออยู่ใน Inbox และผู้ใช้ตั้งค่า Delegation ไว้, **When** มีการส่งงานใหม่เข้ามา, **Then** ระบบมอบหมายให้ Delegatee พร้อมแสดงว่าเป็น "Delegated from [Original User]" +3. **Given** Delegation หมดอายุ, **When** มีงานใหม่ส่งมา, **Then** งานกลับมาที่ผู้ใช้เจ้าของงานเดิม และยกเลิกสิทธิ์ Delegatee + +--- + +### User Story 4 - Auto-Reminders & Escalation (Priority: P2) + +ระบบส่งการแจ้งเตือนอัตโนมัติเมื่องานใกล้ครบกำหนด และส่งขึ้นระดับ (Escalate) เมื่อเกินกำหนด ตาม SLA ที่กำหนดไว้ + +**Why this priority**: ลดความล่าช้าใน workflow และปรับปรุง on-time delivery rate ของเอกสาร + +**Independent Test**: สามารถทดสอบโดยสร้าง RFA ที่มี Due Date ในอีก 1 วัน และตรวจสอบว่าระบบส่ง Reminder ตามที่ตั้งค่าไว้ + +**Acceptance Scenarios**: + +1. **Given** RFA มี Due Date ในอีก 2 วัน, **When** ถึงเวลา 9:00 ของวันที่ตั้งค่า Reminder, **Then** ส่งอีเมล/แจ้งเตือนไปยังผู้รับผิดชอบ +2. **Given** RFA เกินกำหนด 1 วัน, **When** ถึงเงื่อนไข Escalation, **Then** ส่งแจ้งเตือนไปยัง Manager ของผู้รับผิดชอบ พร้อมสถานะ Overdue +3. **Given** Admin ตั้งค่า Reminder Frequency เป็น "Daily" สำหรับงาน Overdue, **When** งานค้างเกินกำหนด, **Then** ส่ง Reminder ทุกวันจนกว่าจะดำเนินการ + +--- + +### User Story 5 - Distribution Matrix (Priority: P2) + +ระบบกระจายเอกสารอัตโนมัติไปยังผู้ที่เกี่ยวข้องหลังจาก RFA ได้รับการอนุมัติ ตาม Distribution Matrix ที่กำหนดไว้ตามประเภทเอกสารและ Response Code + +**Why this priority**: ลด manual work ในการส่งเอกสารต่อ ป้องกันการส่งตกหล่น และสนับสนุนการทำ Transmittal อัตโนมัติ + +**Independent Test**: สามารถทดสอบโดยอนุมัติ RFA ที่มี Distribution Matrix แล้วตรวจสอบว่าเอกสารถูกส่งไปยังผู้รับที่กำหนดใน Matrix โดยอัตโนมัติ + +**Acceptance Scenarios**: + +1. **Given** RFA ประเภท Shop Drawing ได้รับ Code 1A (Full Approval), **When** อนุมัติสำเร็จ, **Then** ระบบส่งเอกสารไปยัง Site Team, QS, และ Document Control ตาม Distribution Matrix +2. **Given** Distribution Matrix มีเงื่อนไข "Send only if Code = 1A, 1B, or 2", **When** RFA ได้รับ Code 3 (Rejected), **Then** ไม่ส่งเอกสารตาม Matrix (แจ้งเฉพาะผู้ส่ง) +3. **Given** Admin อัปเดต Distribution Matrix เพิ่มผู้รับใหม่, **When** RFA ถัดไปได้รับการอนุมัติ, **Then** ส่งเอกสารไปยังผู้รับใหม่ด้วย + +--- + +### User Story 6 - Master Approval Matrix Management (Priority: P3) + +ผู้ดูแลระบบสามารถจัดการ Master Approval Matrix ที่เป็นมาตรฐานขององค์กร แยกตามหมวดงานและสถานะย่อย ให้ใช้งานทั่วทั้งโครงการ + +**Why this priority**: มาตรฐานการอนุมัติให้สอดคล้องกับบริษัทและอุตสาหกรรม ลดความสับสนในการใช้ Response Codes + +**Independent Test**: สามารถทดสอบโดยสร้าง Master Approval Matrix ใหม่ และตรวจสอบว่า RFA แสดง Response Codes ตาม Matrix ที่กำหนด + +**Acceptance Scenarios**: + +1. **Given** Admin ต้องการสร้างมาตรฐานใหม่สำหรับโครงการก่อสร้างสะพาน, **When** สร้าง Master Approval Matrix พร้อมกำหนดหมวดงานและ Sub-status, **Then** สามารถใช้กับโครงการที่เลือกได้ +2. **Given** Master Approval Matrix ถูกใช้งานในโครงการหลายแห่ง, **When** Admin แก้ไข Matrix, **Then** ระบบแสดง Warning ว่าจะมีผลกับโครงการที่ใช้งานอยู่ + +--- + +### Edge Cases + +1. **Race Condition**: สอง Reviewer ในทีมเดียวกันกดอนุมัติพร้อมกัน - ระบบต้องจัดการด้วย Optimistic Locking หรือ Redlock +2. **Circular Delegation**: ผู้ใช้ A Delegate ให้ B, B Delegate ให้ C, C พยายาม Delegate ให้ A - ระบบต้องตรวจจับและป้องกัน +3. **Expired Review Task**: Review Task ค้างนานเกินกำหนดและถูก Reassign - ต้องบันทึกประวัติการเปลี่ยนแปลง +4. **Invalid Response Code**: Reviewer พยายามใช้ Response Code ที่ไม่สอดคล้องกับ Category ของเอกสาร - ระบบต้องแสดงข้อผิดพลาดและไม่บันทึก +5. **Concurrent Review**: หลาย Disciplines ต้อง Review พร้อมกัน แต่มี Discipline หนึ่งปฏิเสธ - ต้องหยุด workflow และแจ้งผู้ส่ง + +--- + +## Requirements + +### Functional Requirements + +**Review Teams & Disciplines** +- **FR-001**: ระบบ MUST รองรับการสร้าง Review Teams ที่มีหลาย Disciplines +- **FR-002**: Review Teams MUST สามารถกำหนดเป็น Default ตามประเภท RFA ได้ +- **FR-003**: เมื่อ RFA เข้า workflow ที่ใช้ Review Team, ระบบ MUST สร้าง Review Tasks สำหรับแต่ละ Discipline พร้อมกัน (Parallel Review) +- **FR-004**: Review Tasks MUST แสดงสถานะรวม (Aggregate Status) เช่น "2 of 3 Disciplines Approved" +- **FR-004.5**: Parallel Review MUST ใช้กฎ Majority with Veto - หากส่วนใหญ่อนุมัติให้ผ่าน แต่หากมี Discipline ใดให้ Code 3 (Rejected) ต้องหยุด workflow และส่งกลับให้แก้ไข + +**Response Codes & Master Approval Matrix** +- **FR-005**: ระบบ MUST ใช้ Master Approval Matrix ตามมาตรฐานที่กำหนด +- **FR-006**: Response Codes MUST แสดงตาม Category ของเอกสาร (Engineering, Material, Contract, Testing, ESG) +- **FR-007**: Code 1C (Change Order), 1D (Alternative), 3 (Rejected) MUST trigger การแจ้งเตือนไปยังฝ่ายที่เกี่ยวข้องโดยอัตโนมัติ +- **FR-008**: ระบบ MUST บันทึกประวัติการเปลี่ยน Response Code (Audit Trail) +- **FR-009**: Reviewer MUST สามารถเพิ่ม Comments พร้อม Response Code ได้ + +**Delegation & Proxy** +- **FR-010**: ผู้ใช้ MUST สามารถตั้งค่า Delegation ได้ พร้อมกำหนดระยะเวลาเริ่มต้น-สิ้นสุด +- **FR-011**: Delegation MUST รองรับการกำหนด Scope (เฉพาะบางประเภทเอกสาร หรือทั้งหมด) +- **FR-012**: ระบบ MUST ตรวจจับ Circular Delegation และป้องกัน +- **FR-013**: เมื่อ Delegation หมดอายุ, งานใหม่ MUST กลับไปยังผู้ใช้เดิมโดยอัตโนมัติ +- **FR-014**: Delegatee MUST เห็นงานที่ได้รับมอบหมายแยกจากงานของตนเอง (Badge "Delegated") + +**Auto-Reminders & Escalation** +- **FR-015**: ระบบ MUST ส่ง Reminder ตาม Schedule ที่กำหนด (1 วันก่อน Due, วัน Due, ทุกวันหลัง Due) +- **FR-016**: Escalation MUST ส่งแจ้งเตือนไปยัง Manager เมื่องาน Overdue ตามเกณฑ์ที่ตั้งไว้ +- **FR-017**: Admin MUST สามารถตั้งค่า Reminder Rules ต่อโครงการ/ประเภทเอกสารได้ +- **FR-018**: ระบบ MUST บันทึกประวัติการส่ง Reminder (ส่งเมื่อไหร่, ใครได้รับ) + +**Frontend Workflow Visualization** +- **FR-019.1**: Parallel Review MUST แสดงผลด้วย **Horizontal Stepper** - แถบความคืบหนันแนวนอนแสดงสถานะแต่ละ Discipline [Structural ▓▓▓░] [Civil ▓▓▓▓] [MEP ▓░░░] +- **FR-019.2**: เมื่อมี Code 3 (Rejected) จาก Discipline ใด MUST แสดง **Modal Dialog** แจ้งการ Veto พร้อมรายละเอียด Discipline ที่ reject +- **FR-019.3**: Navigation ระหว่าง Discipline details MUST ใช้ **Side Panel** layout - ซ้ายรายการ Disciplines, ขวาแสดงรายละเอียด Comments + Attachments ของ Discipline ที่เลือก +- **FR-019.4**: Project Manager MUST สามารถ **Override Veto** ได้ - บังคับผ่าน RFA แม้มี Code 3 จาก Discipline พร้อมบันทึกเหตุผล, Audit trail ว่าเป็น forced approval, และแจ้งเตือนทุก stakeholder ที่เกี่ยวข้อง + +**Distribution Matrix** +- **FR-019**: Distribution Matrix MUST กำหนดผู้รับตาม: ประเภทเอกสาร + Response Code + สถานะเอกสาร +- **FR-020**: ระบบ MUST สร้าง Transmittal Records อัตโนมัติหลังการอนุมัติตาม Distribution Matrix ผ่าน BullMQ Queue (Async, ภายใน 5 นาที) +- **FR-021**: Distribution Matrix MUST รองรับเงื่อนไข "Send Only If" (เช่น Code 1A, 1B เท่านั้น) +- **FR-022**: ระบบ MUST แสดงรายงาน Distribution Status (ส่งแล้วกี่ราย, ค้างกี่ราย) + +**Integration with Existing Systems** +- **FR-023**: ต้องใช้ Unified Workflow Engine (ADR-001) ที่มีอยู่แล้ว +- **FR-024**: ต้องใช้ BullMQ (ADR-008) สำหรับ Reminders และ Distribution Jobs +- **FR-025**: ต้องใช้ CASL (ADR-016) สำหรับสิทธิ์ Reviewer และ Delegation + +### Key Entities + +**ReviewTeam** +- ตัวแทนกลุ่มผู้ตรวจสอบที่จัดการตามสาขาวิชา +- Attributes: name, description, projectId, defaultForRfaTypes, isActive +- Relationships: has many ReviewTeamMember, has many ReviewTask + +**ReviewTeamMember** +- สมาชิกใน Review Team พร้อม Discipline ที่รับผิดชอบ +- Attributes: teamId, userId, disciplineId, role, priorityOrder +- Relationships: belongs to ReviewTeam, belongs to User, belongs to Discipline + +**ReviewTask** +- งานตรวจสอบที่สร้างเมื่อ RFA เข้า workflow +- Attributes: rfaRevisionId, teamId, disciplineId, assignedToUserId, status, dueDate, responseCode, comments +- Relationships: belongs to RfaRevision, belongs to ReviewTeam + +**ResponseCodeMatrix** +- Master Approval Matrix ที่กำหนด Response Codes ตาม Category +- Attributes: code, subStatus, category, descriptionTh, descriptionEn, implications, requiresNotificationTo +- Relationships: has many ResponseCodeRule + +**ResponseCodeRule** +- กฎการใช้ Response Code ตามประเภทเอกสาร +- Attributes: matrixId, documentTypeId, isEnabled, requiresComments, triggersNotification + +**Delegation** +- การมอบหมายอำนาจจากผู้ใช้หนึ่งไปอีกผู้ใช้ +- Attributes: delegatorId, delegateeId, startDate, endDate, scope, documentTypes, isActive +- Relationships: belongs to User (delegator), belongs to User (delegatee) + +**ReminderRule** +- กฎการส่ง Reminder ตาม SLA +- Attributes: name, projectId, documentTypeId, triggerDays, reminderType, recipients, messageTemplate +- Relationships: has many ReminderSchedule + +**DistributionMatrix** +- กำหนดการกระจายเอกสารหลังอนุมัติ +- Attributes: name, documentTypeId, responseCode, status, recipients, conditions, isActive +- Relationships: has many DistributionRecipient + +**DistributionRecipient** +- ผู้รับเอกสารใน Distribution Matrix +- Attributes: matrixId, recipientType (USER/ORGANIZATION/TEAM), recipientId, deliveryMethod + +--- + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: ผู้ใช้สามารถกำหนด Review Team ได้ในเวลาน้อยกว่า 2 นาที (เทียบกับระบบเดิมที่ต้องเลือกรายบุคคล) +- **SC-002**: Response Code ถูกใช้งานได้ถูกต้อง 95%+ ของเวลาทั้งหมด (ลดการใช้ผิดประเภท) +- **SC-003**: ลดเวลาในการอนุมัติ RFA โดยเฉลี่ย 30% จากการใช้ Parallel Review และ Response Codes ที่ชัดเจน +- **SC-004**: 0% ของเอกสารที่ค้างงานเนื่องจากผู้ตรวจสอบไม่อยู่ (ใช้ Delegation) +- **SC-005**: 100% ของเอกสารที่อนุมัติถูกกระจายตาม Distribution Matrix โดยไม่ต้อง manual intervention +- **SC-006**: On-time delivery rate ของ RFA ปรับปรุงจาก baseline เป็นอย่างน้อย 85% +- **SC-007**: ผู้ใช้พึงพอใจกับระบบอนุมัติใหม่ (NPS > 40) + +--- + +## Clarifications + +### Session 2026-05-11 + +- **Q1**: Master Approval Matrix scope and inheritance model? → **A**: Global base + Project overrides - Default Matrix inherited organization-wide, projects can override specific codes/rules as needed (Option B) +- **Q2**: Parallel Review consensus model when Disciplines have conflicting decisions? → **A**: Majority with veto - All Disciplines submit responses, majority determines outcome, but Code 3 (Rejected) from any Discipline vetoes approval and requires revision (Option C) +- **Q3**: Escalation chain depth for overdue RFA reviews? → **A**: 2 levels - Direct manager first, then Project Manager/Director if still unresolved after additional delay (Option B) +- **Q4**: Distribution Matrix execution timing relative to approval? → **A**: Async after approval - Approval returns immediately, distribution queued via BullMQ and processed automatically within 5 minutes (Option C) +- **Q5**: Frontend pattern for displaying Parallel Review progress? → **A**: Horizontal Stepper - แถบความคืบหนันแนวนอนแสดงสถานะแต่ละ Discipline (Option A) +- **Q6**: How to display Veto/Consensus status when Code 3 triggered? → **A**: Modal Dialog - Popup แจ้งเมื่อมีการ Veto พร้อมรายละเอียด Discipline ที่ reject (Option B) +- **Q7**: Navigation pattern between Discipline details for Reviewer? → **A**: Side Panel - ซ้ายรายการ Disciplines, ขวาแสดงรายละเอียดที่เลือก (Option D) +- **Q8**: Can Veto be overridden and by whom? → **A**: Project Manager Override - PM สามารถบังคับผ่าน Veto ได้ พร้อมบันทึกเหตุผล และแจ้งเตือนทุก stakeholder (Option B) + +--- + +## Assumptions + +1. **ผู้ใช้มีความคุ้นเคยกับ Response Codes มาตรฐานอุตสาหกรรม** - จำเป็นต้องมี Training Material ประกอบ +2. **โครงสร้าง Discipline Master Data มีอยู่แล้ว** - ใช้จากระบบ User Management ที่มีอยู่ +3. **Unified Workflow Engine (ADR-001) มีความสามารถรองรับ Parallel Tasks** - อาจต้องปรับปรุง DSL +4. **BullMQ Infrastructure พร้อมใช้งาน** - สำหรับ Reminders และ Background Jobs + +--- + +## Dependencies + +- **ADR-001**: Unified Workflow Engine (ต้องรองรับ Parallel Review Tasks) +- **ADR-008**: BullMQ Notification Strategy +- **ADR-016**: CASL Authorization (สำหรับ Reviewer สิทธิ์) +- **ADR-019**: UUID Strategy (สำหรับ Entities ใหม่) +- **ADR-021**: Workflow Context (สำหรับ Step Attachments) +- **Existing**: Discipline, User, Organization Master Data + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| ผู้ใช้ไม่คุ้นเคยกับ Response Codes ใหม่ | High | ทำ Training Workshop พร้อม Quick Reference Guide | +| Workflow Engine ไม่รองรับ Parallel Review | High | ประเมิน DSL ก่อนเริ่ม, อาจต้อง Refactor Engine | +| Performance ช้าจาก Complex Matrix Lookup | Medium | ทำ Caching สำหรับ Matrix และ Rules | +| Circular Delegation ซับซ้อน | Low | Validation ตั้งแต่ต้น, Limit depth ของ chain | diff --git a/specs/1-rfa-approval-refactor/tasks.md b/specs/1-rfa-approval-refactor/tasks.md new file mode 100644 index 00000000..5571370c --- /dev/null +++ b/specs/1-rfa-approval-refactor/tasks.md @@ -0,0 +1,301 @@ +# Implementation Tasks: RFA Approval System Refactor + +**Feature**: RFA Approval System Refactor (TeamBinder/InEight-Style) +**Branch**: `1-rfa-approval-refactor` +**Generated**: 2026-05-11 + +--- + +## Phase 1: Setup & Infrastructure + +### Goal +Initialize project structure and shared infrastructure for all modules. + +**Independent Test**: All new modules compile without errors, BullMQ queues connect to Redis. + +--- + +- [ ] T001 [P] Create SQL schema file `specs/03-Data-and-Storage/lcbp3-v1.9.0-rfa-approval-schema.sql` with all 9 new entities +- [ ] T002 [P] Create Response Code seeder `backend/src/modules/response-code/seeders/response-code.seed.ts` +- [ ] T003 Create BullMQ queue configuration `backend/src/config/bullmq.config.ts` +- [ ] T004 [P] Setup Redis connection for BullMQ and Redlock `backend/src/config/redis.config.ts` +- [ ] T005 Create shared DTOs and enums `backend/src/modules/review-team/dto/shared/` (ReviewTaskStatus, ResponseCodeCategory, etc.) + +--- + +## Phase 2: Foundational Entities & Services + +### Goal +Core entities required by multiple user stories. Must complete before US1-US6. + +**Independent Test**: CRUD operations work for all entities via API. + +--- + +- [ ] T006 [P] Create ReviewTeam entity `backend/src/modules/review-team/entities/review-team.entity.ts` +- [ ] T007 [P] Create ReviewTeamMember entity `backend/src/modules/review-team/entities/review-team-member.entity.ts` +- [ ] T008 Create ResponseCode entity `backend/src/modules/response-code/entities/response-code.entity.ts` +- [ ] T009 [P] Create ResponseCodeRule entity `backend/src/modules/response-code/entities/response-code-rule.entity.ts` +- [ ] T010 [P] Create ReviewTask entity `backend/src/modules/review-team/entities/review-task.entity.ts` +- [ ] T011 Create ResponseCodeModule with service `backend/src/modules/response-code/response-code.service.ts` +- [ ] T012 Create ResponseCodeController with basic CRUD `backend/src/modules/response-code/response-code.controller.ts` +- [ ] T013 Create ReviewTeamModule base structure `backend/src/modules/review-team/review-team.module.ts` + +--- + +## Phase 3: User Story 1 - Review Teams by Discipline (P1) + +### Goal +Users can create Review Teams with multiple Disciplines, and teams auto-assign to RFA types. + +**Independent Test**: +- Create Review Team via API with 3 disciplines +- Verify team appears in list with member count +- Submit RFA with team → parallel review tasks created + +--- + +- [ ] T014 [US1] Create ReviewTeamService with CRUD and member management `backend/src/modules/review-team/review-team.service.ts` +- [ ] T015 [P] [US1] Create ReviewTeamController endpoints `backend/src/modules/review-team/review-team.controller.ts` +- [ ] T016 [US1] Create ReviewTaskService with assignment logic `backend/src/modules/review-team/review-task.service.ts` +- [ ] T017 [P] [US1] Integrate Review Team selection in RFA submission flow `backend/src/modules/rfa/rfa.service.ts` +- [ ] T018 [US1] Implement parallel task creation on RFA submit `backend/src/modules/review-team/services/task-creation.service.ts` +- [ ] T019 [P] [US1] Create Review Team management UI page `frontend/src/app/(dashboard)/review-teams/page.tsx` +- [ ] T020 [P] [US1] Create Review Team form component `frontend/src/components/review-team/ReviewTeamForm.tsx` +- [ ] T021 [US1] Create Team Member assignment component `frontend/src/components/review-team/TeamMemberManager.tsx` +- [ ] T022 [P] [US1] Create useReviewTeams hook `frontend/src/hooks/use-review-teams.ts` +- [ ] T023 [US1] Add Review Team selector to RFA submission form `frontend/src/app/(dashboard)/rfa/[id]/submit/page.tsx` + +--- + +## Phase 4: User Story 2 - Response Codes & Master Approval Matrix (P1) + +### Goal +Response Codes display by document category, Code 1C/1D/3 trigger notifications, full audit trail. + +**Independent Test**: +- RFA review page shows only Engineering codes for Shop Drawing +- Select Code 1C → notification sent to Contract team +- Change response code → audit logged + +--- + +- [ ] T024 [US2] Extend ResponseCodeService with category filtering `backend/src/modules/response-code/response-code.service.ts` +- [ ] T025 [P] [US2] Create ResponseCode lookup endpoint by document type `backend/src/modules/response-code/response-code.controller.ts` +- [ ] T026 [US2] Implement Response Code implications evaluator `backend/src/modules/response-code/services/implications.service.ts` +- [ ] T027 [P] [US2] Create notification trigger service for critical codes `backend/src/modules/response-code/services/notification-trigger.service.ts` +- [ ] T028 [US2] Add audit logging for Response Code changes `backend/src/modules/response-code/services/audit.service.ts` +- [ ] T029 [P] [US2] Create Response Code selector component with category filtering `frontend/src/components/response-code/ResponseCodeSelector.tsx` +- [ ] T030 [US2] Create Response Code implications display `frontend/src/components/response-code/CodeImplications.tsx` +- [ ] T031 [P] [US2] Create Master Approval Matrix admin UI `frontend/src/app/(dashboard)/response-codes/page.tsx` +- [ ] T032 [US2] Create useResponseCodes hook with category filter `frontend/src/hooks/use-response-codes.ts` +- [ ] T033 [P] [US2] Integrate Response Code selector in Review Task completion UI `frontend/src/components/review-task/CompleteReviewForm.tsx` + +--- + +## Phase 5: User Story 3 - Delegation & Proxy (P2) + +### Goal +Users can delegate review tasks with date range, circular detection prevents loops. + +**Independent Test**: +- User A delegates to User B for 1 week +- RFA assigned to A during period → automatically assigned to B +- Try to create A→B→C→A → error prevented + +--- + +- [ ] T034 [US3] Create Delegation entity `backend/src/modules/delegation/entities/delegation.entity.ts` +- [ ] T035 [P] [US3] Create DelegationService with CRUD `backend/src/modules/delegation/delegation.service.ts` +- [ ] T036 [US3] Implement circular delegation detection algorithm `backend/src/modules/delegation/services/circular-detection.service.ts` +- [ ] T037 [P] [US3] Create DelegationController endpoints `backend/src/modules/delegation/delegation.controller.ts` +- [ ] T038 [US3] Integrate delegation resolution in ReviewTaskService `backend/src/modules/review-team/review-task.service.ts` +- [ ] T039 [P] [US3] Create Delegation settings UI page `frontend/src/app/(dashboard)/delegation/page.tsx` +- [ ] T040 [US3] Create Delegation form with date picker `frontend/src/components/delegation/DelegationForm.tsx` +- [ ] T041 [P] [US3] Create delegated task indicator ("Delegated from X") `frontend/src/components/review-task/DelegatedBadge.tsx` +- [ ] T042 [P] [US3] Create useDelegation hook `frontend/src/hooks/use-delegation.ts` + +--- + +## Phase 6: User Story 4 - Auto-Reminders & Escalation (P2) + +### Goal +Scheduled reminders via BullMQ, 2-level escalation when overdue. + +**Independent Test**: +- RFA due in 2 days → reminder scheduled +- Past due date → escalation level 1 notification +- 3 days overdue → escalation level 2 notification + +--- + +- [ ] T043 [US4] Create ReminderRule entity `backend/src/modules/reminder/entities/reminder-rule.entity.ts` +- [ ] T044 [P] [US4] Create ReminderService with BullMQ integration `backend/src/modules/reminder/reminder.service.ts` +- [ ] T045 [US4] Implement reminder scheduling on RFA submit `backend/src/modules/reminder/services/scheduler.service.ts` +- [ ] T046 [P] [US4] Create ReminderProcessor for queue workers `backend/src/modules/reminder/processors/reminder.processor.ts` +- [ ] T047 [US4] Implement 2-level escalation logic `backend/src/modules/reminder/services/escalation.service.ts` +- [ ] T048 [P] [US4] Create ReminderRuleController admin endpoints `backend/src/modules/reminder/reminder.controller.ts` +- [ ] T049 [P] [US4] Create ReminderRule admin UI `frontend/src/app/(dashboard)/reminder-rules/page.tsx` +- [ ] T050 [US4] Create reminder history viewer `frontend/src/components/reminder/ReminderHistory.tsx` + +--- + +## Phase 7: User Story 5 - Distribution Matrix (P2) + +### Goal +Async distribution after approval, Transmittal records created via BullMQ. + +**Independent Test**: +- RFA approved with Code 1A → distribution queued +- Distribution job processed within 5 minutes +- Recipients receive email and in-app notification + +--- + +- [ ] T051 [US5] Create DistributionMatrix entity `backend/src/modules/distribution/entities/distribution-matrix.entity.ts` +- [ ] T052 [P] [US5] Create DistributionRecipient entity `backend/src/modules/distribution/entities/distribution-recipient.entity.ts` +- [ ] T053 [US5] Create DistributionMatrixService with CRUD `backend/src/modules/distribution/distribution-matrix.service.ts` +- [ ] T054 [P] [US5] Create DistributionService with BullMQ integration `backend/src/modules/distribution/distribution.service.ts` +- [ ] T055 [US5] Implement distribution triggering on approval `backend/src/modules/distribution/services/approval-listener.service.ts` +- [ ] T056 [P] [US5] Create DistributionProcessor for queue workers `backend/src/modules/distribution/processors/distribution.processor.ts` +- [ ] T057 [US5] Create Transmittal records from distribution `backend/src/modules/distribution/services/transmittal-creator.service.ts` +- [ ] T058 [P] [US5] Create DistributionMatrixController `backend/src/modules/distribution/distribution.controller.ts` +- [ ] T059 [P] [US5] Create Distribution Matrix admin UI `frontend/src/app/(dashboard)/distribution-matrices/page.tsx` +- [ ] T060 [US5] Create distribution status dashboard `frontend/src/components/distribution/DistributionStatus.tsx` + +--- + +## Phase 8: User Story 6 - Master Approval Matrix Management (P3) + +### Goal +Admin UI for managing Matrix, project overrides with inheritance tracking. + +**Independent Test**: +- View global Matrix with all categories and codes +- Create project-specific override for Code 1C +- Override appears only for that project + +--- + +- [ ] T061 [US6] Extend ResponseCodeService with project overrides `backend/src/modules/response-code/services/matrix-management.service.ts` +- [ ] T062 [P] [US6] Create Matrix inheritance resolver `backend/src/modules/response-code/services/inheritance.service.ts` +- [ ] T063 [US6] Add Matrix management endpoints to ResponseCodeController `backend/src/modules/response-code/response-code.controller.ts` +- [ ] T064 [P] [US6] Create Master Approval Matrix visual editor `frontend/src/components/response-code/MatrixEditor.tsx` +- [ ] T065 [US6] Create project override management UI `frontend/src/components/response-code/ProjectOverrideManager.tsx` + +--- + +## Phase 9: Cross-Cutting & Polish + +### Goal +Workflow Engine integration, aggregate status, edge case handling, testing. + +**Independent Test**: +- Complete end-to-end workflow: RFA submit → parallel review → consensus → distribution +- All edge cases handled (race conditions, circular delegation, veto) + +--- + +- [ ] T066 Extend WorkflowEngine DSL with Parallel Gateway support `backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts` +- [ ] T067 [P] Implement Review Task aggregate status calculator `backend/src/modules/review-team/services/aggregate-status.service.ts` +- [ ] T068 [P] Create consensus evaluation service `backend/src/modules/review-team/services/consensus.service.ts` +- [ ] T068.5 Implement Veto Override for Project Manager `backend/src/modules/review-team/services/veto-override.service.ts` - พร้อม audit trail และ notification +- [ ] T069 Implement race condition handling (Redlock) in ReviewTask completion `backend/src/modules/review-team/review-task.service.ts` +- [ ] T070 [P] Add optimistic locking to ReviewTask entity `backend/src/modules/review-team/entities/review-task.entity.ts` +- [ ] T071 Create Review Task inbox UI with aggregate status `frontend/src/components/review-task/ReviewTaskInbox.tsx` +- [ ] T072 [P] Create parallel review progress indicator `frontend/src/components/review-task/ParallelProgress.tsx` +- [ ] T072.5 Create Veto Override button and modal for PM `frontend/src/components/review-task/VetoOverrideDialog.tsx` - พร้อม input สำหรับ justification reason +- [ ] T073 Add validation for all edge cases in service layer `backend/src/common/validators/review-validators.ts` +- [ ] T074 [P] Create unit tests for ResponseCodeService `backend/tests/unit/response-code/response-code.service.spec.ts` +- [ ] T075 [P] Create unit tests for Delegation circular detection `backend/tests/unit/delegation/circular-detection.service.spec.ts` +- [ ] T076 [P] Create integration tests for parallel review consensus `backend/tests/integration/review-team/parallel-review.spec.ts` +- [ ] T077 Create e2e tests for complete RFA workflow `backend/tests/e2e/rfa-workflow.e2e-spec.ts` +- [ ] T078 [P] Add frontend tests for ResponseCodeSelector `frontend/tests/components/ResponseCodeSelector.test.tsx` +- [ ] T079 Update quickstart.md with final setup instructions `specs/1-rfa-approval-refactor/quickstart.md` +- [ ] T080 [P] Run full test suite and fix any failures `npm test` + +--- + +## Dependency Graph + +``` +Phase 1: Setup + │ + ▼ +Phase 2: Foundational Entities + │ + ├───> Phase 3: US1 Review Teams ───────┐ + │ │ + ├───> Phase 4: US2 Response Codes ────┼──┐ + │ │ │ + ├───> Phase 5: US3 Delegation ────────┤ │ + │ │ │ + ├───> Phase 6: US4 Reminders ──────────┤ │ + │ │ │ + └───> Phase 7: US5 Distribution ───────┼──┤ + │ │ +Phase 8: US6 Matrix Management <──────────┘ │ + │ +Phase 9: Polish & Integration <───────────────┘ +``` + +--- + +## Parallel Execution Opportunities + +| Phase | Parallel Tasks | Description | +|-------|----------------|-------------| +| Phase 1 | T001, T002, T004, T005 | SQL, Seeder, Redis config, DTOs | +| Phase 2 | T006, T007, T009, T010 | Entity creation | +| Phase 3 | T015, T019, T020, T022 | Controller + Frontend components | +| Phase 4 | T025, T027, T029, T031 | API + UI parallel | +| Phase 5 | T035, T037, T039, T040, T042 | Backend + Frontend | +| Phase 6 | T044, T046, T049 | Reminder service + processor + UI | +| Phase 7 | T052, T054, T056, T058, T059 | Distribution entities + service + processor + UI | +| Phase 9 | T067, T068, T070, T074, T075, T078 | Status calc + Locking + Tests | + +--- + +## MVP Scope (Minimum Viable Product) + +For fastest value delivery, implement: + +1. **Phase 1-2**: Setup and entities +2. **Phase 3**: US1 Review Teams only +3. **Phase 9**: Basic consensus + edge case handling (skip US2-US6) + +**MVP Deliverables**: +- Review Teams with Disciplines +- Parallel review task creation +- Basic response code selection (no category filtering) +- Simple sequential workflow (no parallel gateway in DSL yet) + +--- + +## Total Task Summary + +| Phase | Tasks | Story | +|-------|-------|-------| +| Phase 1 | 5 | Setup | +| Phase 2 | 8 | Foundational | +| Phase 3 | 10 | US1 | +| Phase 4 | 10 | US2 | +| Phase 5 | 9 | US3 | +| Phase 6 | 8 | US4 | +| Phase 7 | 10 | US5 | +| Phase 8 | 5 | US6 | +| Phase 9 | 17 | Polish | +| **Total** | **82** | - | + +--- + +## Next Steps + +1. **Execute Phase 1-2**: Setup and entities +2. **Run `/speckit-analyze`**: Validate cross-artifact consistency +3. **Implement incrementally**: Start with MVP (Phases 1-3 + minimal Phase 9) +4. **Test independently**: Each user story should be independently testable + +--- + +**Ready for implementation** ✅ From ef20839f99c949e4745c0b4b4f9f179693c59848 Mon Sep 17 00:00:00 2001 From: Nattanin Date: Tue, 12 May 2026 16:17:27 +0700 Subject: [PATCH 2/2] feat(rfa): complete RFA Approval Refactor - all 9 phases (T001-T080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1-2: Setup, SQL schema, enums, queue constants, base entities Phase 3 (US1): ReviewTeam, ReviewTeamMember, ReviewTask, TaskCreationService Phase 4 (US2): ResponseCode, ResponseCodeRule, ImplicationsService, NotificationTriggerService Phase 5 (US3): Delegation entity, CircularDetectionService, DelegationService/Controller/Module Phase 6 (US4): ReminderRule, SchedulerService, EscalationService, ReminderProcessor, ReminderModule Phase 7 (US5): DistributionMatrix, DistributionRecipient, ApprovalListenerService (Strangler), TransmittalCreatorService, DistributionProcessor, DistributionModule Phase 8 (US6): MatrixManagementService, InheritanceService (global→project override) Phase 9 (Polish): AggregateStatusService, ConsensusService, VetoOverrideService, ParallelGatewayHandler, review-validators, optimistic locking in completeReview, test stubs (unit/integration/e2e), jest.config.js updated for tests/ directory Frontend: ReviewTaskInbox, ParallelProgress, VetoOverrideDialog, DelegationForm, DelegatedBadge, MatrixEditor, ProjectOverrideManager, DistributionStatus, ReminderHistory, ResponseCodeSelector, CodeImplications, CompleteReviewForm, ReviewTeamForm, ReviewTeamSelector, TeamMemberManager Closes #1 --- backend/jest.config.js | 40 +-- backend/src/app.module.ts | 10 + .../common/validators/review-validators.ts | 73 +++++ .../common/constants/queue.constants.ts | 18 ++ .../src/modules/common/enums/review.enums.ts | 69 +++++ .../delegation/delegation.controller.ts | 40 +++ .../modules/delegation/delegation.module.ts | 20 ++ .../modules/delegation/delegation.service.ts | 115 +++++++ .../delegation/dto/create-delegation.dto.ts | 31 ++ .../delegation/entities/delegation.entity.ts | 67 +++++ .../services/circular-detection.service.ts | 76 +++++ .../distribution-matrix.service.ts | 84 ++++++ .../distribution/distribution.controller.ts | 50 ++++ .../distribution/distribution.module.ts | 32 ++ .../distribution/distribution.service.ts | 53 ++++ .../entities/distribution-matrix.entity.ts | 49 +++ .../entities/distribution-recipient.entity.ts | 55 ++++ .../processors/distribution.processor.ts | 39 +++ .../services/approval-listener.service.ts | 56 ++++ .../services/transmittal-creator.service.ts | 69 +++++ .../reminder/dto/create-reminder-rule.dto.ts | 29 ++ .../reminder/entities/reminder-rule.entity.ts | 49 +++ .../reminder/processors/reminder.processor.ts | 74 +++++ .../modules/reminder/reminder.controller.ts | 37 +++ .../src/modules/reminder/reminder.module.ts | 30 ++ .../src/modules/reminder/reminder.service.ts | 51 ++++ .../reminder/services/escalation.service.ts | 112 +++++++ .../reminder/services/scheduler.service.ts | 97 ++++++ .../entities/response-code-rule.entity.ts | 56 ++++ .../entities/response-code.entity.ts | 61 ++++ .../response-code/response-code.controller.ts | 53 ++++ .../response-code/response-code.module.ts | 36 +++ .../response-code/response-code.service.ts | 94 ++++++ .../seeders/response-code.seed.ts | 281 ++++++++++++++++++ .../services/implications.service.ts | 110 +++++++ .../services/inheritance.service.ts | 121 ++++++++ .../services/matrix-management.service.ts | 103 +++++++ .../services/notification-trigger.service.ts | 86 ++++++ .../review-team/dto/shared/review-team.dto.ts | 179 +++++++++++ .../entities/review-task.entity.ts | 98 ++++++ .../entities/review-team-member.entity.ts | 60 ++++ .../entities/review-team.entity.ts | 52 ++++ .../review-team/review-task.service.ts | 179 +++++++++++ .../review-team/review-team.controller.ts | 92 ++++++ .../modules/review-team/review-team.module.ts | 66 ++++ .../review-team/review-team.service.ts | 188 ++++++++++++ .../services/aggregate-status.service.ts | 107 +++++++ .../review-team/services/consensus.service.ts | 95 ++++++ .../services/task-creation.service.ts | 112 +++++++ .../services/veto-override.service.ts | 70 +++++ backend/src/modules/rfa/dto/submit-rfa.dto.ts | 11 +- .../dsl/parallel-gateway.handler.ts | 85 ++++++ backend/tests/e2e/rfa-workflow.e2e-spec.ts | 38 +++ .../review-team/parallel-review.spec.ts | 49 +++ .../circular-detection.service.spec.ts | 69 +++++ .../response-code.service.spec.ts | 67 +++++ .../(dashboard)/settings/delegation/page.tsx | 120 ++++++++ .../settings/review-teams/page.tsx | 167 +++++++++++ .../components/delegation/DelegationForm.tsx | 178 +++++++++++ .../distribution/DistributionStatus.tsx | 87 ++++++ .../components/reminder/ReminderHistory.tsx | 84 ++++++ .../response-code/CodeImplications.tsx | 82 +++++ .../components/response-code/MatrixEditor.tsx | 163 ++++++++++ .../response-code/ProjectOverrideManager.tsx | 156 ++++++++++ .../response-code/ResponseCodeSelector.tsx | 102 +++++++ .../review-task/CompleteReviewForm.tsx | 113 +++++++ .../components/review-task/DelegatedBadge.tsx | 39 +++ .../review-task/ParallelProgress.tsx | 92 ++++++ .../review-task/ReviewTaskInbox.tsx | 186 ++++++++++++ .../review-task/VetoOverrideDialog.tsx | 101 +++++++ .../components/review-team/ReviewTeamForm.tsx | 145 +++++++++ .../review-team/ReviewTeamSelector.tsx | 84 ++++++ .../review-team/TeamMemberManager.tsx | 184 ++++++++++++ frontend/hooks/use-delegation.ts | 82 +++++ frontend/hooks/use-response-codes.ts | 45 +++ frontend/hooks/use-review-teams.ts | 118 ++++++++ frontend/lib/services/review-team.service.ts | 75 +++++ .../components/ResponseCodeSelector.test.tsx | 71 +++++ frontend/types/review-team.ts | 100 +++++++ .../lcbp3-v1.9.0-rfa-approval-schema.sql | 216 ++++++++++++++ specs/1-rfa-approval-refactor/quickstart.md | 59 +++- specs/1-rfa-approval-refactor/tasks.md | 164 +++++----- 82 files changed, 7052 insertions(+), 104 deletions(-) create mode 100644 backend/src/common/validators/review-validators.ts create mode 100644 backend/src/modules/common/constants/queue.constants.ts create mode 100644 backend/src/modules/common/enums/review.enums.ts create mode 100644 backend/src/modules/delegation/delegation.controller.ts create mode 100644 backend/src/modules/delegation/delegation.module.ts create mode 100644 backend/src/modules/delegation/delegation.service.ts create mode 100644 backend/src/modules/delegation/dto/create-delegation.dto.ts create mode 100644 backend/src/modules/delegation/entities/delegation.entity.ts create mode 100644 backend/src/modules/delegation/services/circular-detection.service.ts create mode 100644 backend/src/modules/distribution/distribution-matrix.service.ts create mode 100644 backend/src/modules/distribution/distribution.controller.ts create mode 100644 backend/src/modules/distribution/distribution.module.ts create mode 100644 backend/src/modules/distribution/distribution.service.ts create mode 100644 backend/src/modules/distribution/entities/distribution-matrix.entity.ts create mode 100644 backend/src/modules/distribution/entities/distribution-recipient.entity.ts create mode 100644 backend/src/modules/distribution/processors/distribution.processor.ts create mode 100644 backend/src/modules/distribution/services/approval-listener.service.ts create mode 100644 backend/src/modules/distribution/services/transmittal-creator.service.ts create mode 100644 backend/src/modules/reminder/dto/create-reminder-rule.dto.ts create mode 100644 backend/src/modules/reminder/entities/reminder-rule.entity.ts create mode 100644 backend/src/modules/reminder/processors/reminder.processor.ts create mode 100644 backend/src/modules/reminder/reminder.controller.ts create mode 100644 backend/src/modules/reminder/reminder.module.ts create mode 100644 backend/src/modules/reminder/reminder.service.ts create mode 100644 backend/src/modules/reminder/services/escalation.service.ts create mode 100644 backend/src/modules/reminder/services/scheduler.service.ts create mode 100644 backend/src/modules/response-code/entities/response-code-rule.entity.ts create mode 100644 backend/src/modules/response-code/entities/response-code.entity.ts create mode 100644 backend/src/modules/response-code/response-code.controller.ts create mode 100644 backend/src/modules/response-code/response-code.module.ts create mode 100644 backend/src/modules/response-code/response-code.service.ts create mode 100644 backend/src/modules/response-code/seeders/response-code.seed.ts create mode 100644 backend/src/modules/response-code/services/implications.service.ts create mode 100644 backend/src/modules/response-code/services/inheritance.service.ts create mode 100644 backend/src/modules/response-code/services/matrix-management.service.ts create mode 100644 backend/src/modules/response-code/services/notification-trigger.service.ts create mode 100644 backend/src/modules/review-team/dto/shared/review-team.dto.ts create mode 100644 backend/src/modules/review-team/entities/review-task.entity.ts create mode 100644 backend/src/modules/review-team/entities/review-team-member.entity.ts create mode 100644 backend/src/modules/review-team/entities/review-team.entity.ts create mode 100644 backend/src/modules/review-team/review-task.service.ts create mode 100644 backend/src/modules/review-team/review-team.controller.ts create mode 100644 backend/src/modules/review-team/review-team.module.ts create mode 100644 backend/src/modules/review-team/review-team.service.ts create mode 100644 backend/src/modules/review-team/services/aggregate-status.service.ts create mode 100644 backend/src/modules/review-team/services/consensus.service.ts create mode 100644 backend/src/modules/review-team/services/task-creation.service.ts create mode 100644 backend/src/modules/review-team/services/veto-override.service.ts create mode 100644 backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts create mode 100644 backend/tests/e2e/rfa-workflow.e2e-spec.ts create mode 100644 backend/tests/integration/review-team/parallel-review.spec.ts create mode 100644 backend/tests/unit/delegation/circular-detection.service.spec.ts create mode 100644 backend/tests/unit/response-code/response-code.service.spec.ts create mode 100644 frontend/app/(dashboard)/settings/delegation/page.tsx create mode 100644 frontend/app/(dashboard)/settings/review-teams/page.tsx create mode 100644 frontend/components/delegation/DelegationForm.tsx create mode 100644 frontend/components/distribution/DistributionStatus.tsx create mode 100644 frontend/components/reminder/ReminderHistory.tsx create mode 100644 frontend/components/response-code/CodeImplications.tsx create mode 100644 frontend/components/response-code/MatrixEditor.tsx create mode 100644 frontend/components/response-code/ProjectOverrideManager.tsx create mode 100644 frontend/components/response-code/ResponseCodeSelector.tsx create mode 100644 frontend/components/review-task/CompleteReviewForm.tsx create mode 100644 frontend/components/review-task/DelegatedBadge.tsx create mode 100644 frontend/components/review-task/ParallelProgress.tsx create mode 100644 frontend/components/review-task/ReviewTaskInbox.tsx create mode 100644 frontend/components/review-task/VetoOverrideDialog.tsx create mode 100644 frontend/components/review-team/ReviewTeamForm.tsx create mode 100644 frontend/components/review-team/ReviewTeamSelector.tsx create mode 100644 frontend/components/review-team/TeamMemberManager.tsx create mode 100644 frontend/hooks/use-delegation.ts create mode 100644 frontend/hooks/use-response-codes.ts create mode 100644 frontend/hooks/use-review-teams.ts create mode 100644 frontend/lib/services/review-team.service.ts create mode 100644 frontend/tests/components/ResponseCodeSelector.test.tsx create mode 100644 frontend/types/review-team.ts create mode 100644 specs/03-Data-and-Storage/lcbp3-v1.9.0-rfa-approval-schema.sql diff --git a/backend/jest.config.js b/backend/jest.config.js index e80b6927..a4c5a814 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -14,10 +14,14 @@ module.exports = { moduleFileExtensions: ['js', 'json', 'ts'], // Root directory for tests - rootDir: 'src', + rootDir: '.', - // Test file pattern - testRegex: '.*\\.spec\\.ts$', + // Test file pattern — ครอบคลุมทั้ง src/ (unit) และ tests/ (integration/e2e) + testMatch: [ + '/src/**/*.spec.ts', + '/tests/**/*.spec.ts', + '/tests/**/*.e2e-spec.ts', + ], // TypeScript transformation transform: { @@ -30,16 +34,16 @@ module.exports = { // Coverage configuration collectCoverageFrom: [ - '**/*.(t|j)s', - '!**/*.d.ts', - '!**/index.ts', - '!**/database/seeds/**', - '!**/database/migrations/**', - '!**/config/**', - '!**/scripts/**', - '!**/*.module.ts', + 'src/**/*.(t|j)s', + '!src/**/*.d.ts', + '!src/**/index.ts', + '!src/**/database/seeds/**', + '!src/**/database/migrations/**', + '!src/**/config/**', + '!src/**/scripts/**', + '!src/**/*.module.ts', ], - coverageDirectory: '../coverage', + coverageDirectory: './coverage', coveragePathIgnorePatterns: ['/node_modules/', '/test/', '/dist/'], // Test environment @@ -49,7 +53,7 @@ module.exports = { cacheDirectory: '.jest-cache', // Global setup after env - setupFilesAfterEnv: ['../test/jest.setup.ts'], + setupFilesAfterEnv: ['./test/jest.setup.ts'], // Transform ignore patterns (ให้ Jest ประมวลผล ESM modules) // รองรับ uuid และ @nestjs/elasticsearch ที่เป็น ESM @@ -100,11 +104,11 @@ module.exports = { // Module name mapper for path aliases moduleNameMapper: { - '^@/(.*)$': '/$1', - '^@common/(.*)$': '/common/$1', - '^@modules/(.*)$': '/modules/$1', - '^@config/(.*)$': '/config/$1', - '^@database/(.*)$': '/database/$1', + '^@/(.*)$': '/src/$1', + '^@common/(.*)$': '/src/common/$1', + '^@modules/(.*)$': '/src/modules/$1', + '^@config/(.*)$': '/src/config/$1', + '^@database/(.*)$': '/src/database/$1', }, // Verbose output for debugging diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 12fdc450..b263af9f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -53,6 +53,11 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module'; import { MigrationModule } from './modules/migration/migration.module'; import { AiModule } from './modules/ai/ai.module'; import { RagModule } from './modules/rag/rag.module'; +import { ReviewTeamModule } from './modules/review-team/review-team.module'; +import { ResponseCodeModule } from './modules/response-code/response-code.module'; +import { DelegationModule } from './modules/delegation/delegation.module'; +import { ReminderModule } from './modules/reminder/reminder.module'; +import { DistributionModule } from './modules/distribution/distribution.module'; @Module({ imports: [ @@ -191,6 +196,11 @@ import { RagModule } from './modules/rag/rag.module'; MigrationModule, AiModule, RagModule, + ReviewTeamModule, + ResponseCodeModule, + DelegationModule, + ReminderModule, + DistributionModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/validators/review-validators.ts b/backend/src/common/validators/review-validators.ts new file mode 100644 index 00000000..a7c0dddf --- /dev/null +++ b/backend/src/common/validators/review-validators.ts @@ -0,0 +1,73 @@ +// File: src/common/validators/review-validators.ts +// Edge case validators สำหรับ RFA Review workflow (T073) + +/** + * ตรวจสอบว่า due date ถูกต้อง (ต้องอยู่ในอนาคต) + */ +export function validateDueDate(dueDate: Date): void { + const now = new Date(); + if (dueDate <= now) { + throw new Error('Due date must be in the future'); + } +} + +/** + * ตรวจสอบ delegation date range ไม่เกิน 90 วัน + */ +export function validateDelegationDateRange(startDate: Date, endDate: Date): void { + if (endDate <= startDate) { + throw new Error('End date must be after start date'); + } + + const maxDays = 90; + const diffMs = endDate.getTime() - startDate.getTime(); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffDays > maxDays) { + throw new Error(`Delegation period cannot exceed ${maxDays} days`); + } +} + +/** + * ตรวจสอบ ReviewTask ว่าสามารถ complete ได้ (ต้องมี response code) + */ +export function validateTaskCompletionRequirements( + taskStatus: string, + responseCodeId: number | undefined | null, + requiresComments: boolean, + comments: string | undefined | null, +): void { + if (taskStatus === 'COMPLETED') { + if (!responseCodeId) { + throw new Error('Response code is required to complete a review task'); + } + + if (requiresComments && (!comments || comments.trim().length === 0)) { + throw new Error('Comments are required for this response code'); + } + } +} + +/** + * ตรวจสอบ version สำหรับ optimistic locking (ADR-002) + */ +export function validateVersion( + expectedVersion: number, + actualVersion: number, + entityName: string, +): void { + if (actualVersion !== expectedVersion) { + throw new Error( + `Optimistic lock conflict on ${entityName}: expected version ${expectedVersion}, got ${actualVersion}. Please retry.`, + ); + } +} + +/** + * ตรวจสอบว่า override reason มีความยาวเพียงพอ + */ +export function validateOverrideReason(reason: string, minLength = 10): void { + if (!reason || reason.trim().length < minLength) { + throw new Error(`Override reason must be at least ${minLength} characters`); + } +} diff --git a/backend/src/modules/common/constants/queue.constants.ts b/backend/src/modules/common/constants/queue.constants.ts new file mode 100644 index 00000000..3b9a2851 --- /dev/null +++ b/backend/src/modules/common/constants/queue.constants.ts @@ -0,0 +1,18 @@ +// File: src/modules/common/constants/queue.constants.ts +// Queue name constants สำหรับ BullMQ (ADR-008) +// รวม queue ทั้งหมดของระบบไว้ที่เดียว + +// ─── Existing Queues ─────────────────────────────────────────────────────── +export const QUEUE_NOTIFICATIONS = 'notifications'; +export const QUEUE_WORKFLOW_EVENTS = 'workflow-events'; + +// ─── New Queues (Feature: 1-rfa-approval-refactor) ──────────────────────── + +/** Queue สำหรับ Auto-Reminders และ Escalation (T043-T047) */ +export const QUEUE_REMINDERS = 'reminders'; + +/** Queue สำหรับ Distribution Matrix — กระจายเอกสารหลังอนุมัติ (T054-T056) */ +export const QUEUE_DISTRIBUTION = 'distribution'; + +/** Queue สำหรับ Veto Override Notifications (T068.5) */ +export const QUEUE_VETO_NOTIFICATIONS = 'veto-notifications'; diff --git a/backend/src/modules/common/enums/review.enums.ts b/backend/src/modules/common/enums/review.enums.ts new file mode 100644 index 00000000..3ae8a7d6 --- /dev/null +++ b/backend/src/modules/common/enums/review.enums.ts @@ -0,0 +1,69 @@ +// File: src/modules/common/enums/review.enums.ts +// Shared enums สำหรับ RFA Approval Refactor (Feature: 1-rfa-approval-refactor) + +// ─── Review Task Status ──────────────────────────────────────────────────── +export enum ReviewTaskStatus { + PENDING = 'PENDING', // รอดำเนินการ + IN_PROGRESS = 'IN_PROGRESS', // กำลังตรวจสอบ + COMPLETED = 'COMPLETED', // เสร็จสิ้น (มีผลลัพธ์) + DELEGATED = 'DELEGATED', // ถูกมอบหมายให้ผู้อื่น + EXPIRED = 'EXPIRED', // เกินกำหนด + CANCELLED = 'CANCELLED', // ยกเลิก +} + +// ─── Response Code Category ──────────────────────────────────────────────── +export enum ResponseCodeCategory { + ENGINEERING = 'ENGINEERING', // Shop Drawing / Method Statement / As-Built + MATERIAL = 'MATERIAL', // Material / Procurement Submittal + CONTRACT = 'CONTRACT', // Contract / Cost / BOQ + TESTING = 'TESTING', // Testing / Handover / QA + ESG = 'ESG', // Environment / Social / Governance +} + +// ─── Delegation Scope ────────────────────────────────────────────────────── +export enum DelegationScope { + ALL = 'ALL', // มอบหมายทุกงาน + RFA_ONLY = 'RFA_ONLY', // เฉพาะงาน RFA + CORRESPONDENCE_ONLY = 'CORRESPONDENCE_ONLY', // เฉพาะงาน Correspondence + SPECIFIC_TYPES = 'SPECIFIC_TYPES', // กำหนดประเภทเอกสารเอง +} + +// ─── Reminder Type ───────────────────────────────────────────────────────── +export enum ReminderType { + DUE_SOON = 'DUE_SOON', // X วันก่อนครบกำหนด + ON_DUE = 'ON_DUE', // วันครบกำหนด + OVERDUE = 'OVERDUE', // หลังครบกำหนด (ส่งซ้ำทุกวัน) + ESCALATION_L1 = 'ESCALATION_L1', // Escalation ระดับ 1 (ถึง Manager) + ESCALATION_L2 = 'ESCALATION_L2', // Escalation ระดับ 2 (ถึง PM/Director) +} + +// ─── Review Team Member Role ─────────────────────────────────────────────── +export enum ReviewTeamMemberRole { + REVIEWER = 'REVIEWER', // ผู้ตรวจสอบ + LEAD = 'LEAD', // หัวหน้าทีม (Lead Reviewer) + MANAGER = 'MANAGER', // ผู้จัดการ (Escalation target) +} + +// ─── Distribution Recipient Type ────────────────────────────────────────── +export enum RecipientType { + USER = 'USER', // ผู้ใช้เฉพาะคน + ORGANIZATION = 'ORGANIZATION', // องค์กร + TEAM = 'TEAM', // ทีม + ROLE = 'ROLE', // บทบาท เช่น ALL_QS, ALL_SITE_ENG +} + +// ─── Distribution Delivery Method ───────────────────────────────────────── +export enum DeliveryMethod { + EMAIL = 'EMAIL', // ส่งอีเมล + IN_APP = 'IN_APP', // แจ้งเตือนในระบบ + BOTH = 'BOTH', // ทั้งสองช่องทาง +} + +// ─── Consensus Decision (Parallel Review) ───────────────────────────────── +export enum ConsensusDecision { + APPROVED = 'APPROVED', // ผ่าน (Majority approved) + REJECTED = 'REJECTED', // ไม่ผ่าน (Veto triggered by Code 3) + APPROVED_WITH_COMMENTS = 'APPROVED_WITH_COMMENTS', // ผ่านพร้อมหมายเหตุ + PENDING = 'PENDING', // รอผล (ยังไม่ครบทุก Discipline) + OVERRIDDEN = 'OVERRIDDEN', // PM Override — บังคับผ่าน +} diff --git a/backend/src/modules/delegation/delegation.controller.ts b/backend/src/modules/delegation/delegation.controller.ts new file mode 100644 index 00000000..3d34d862 --- /dev/null +++ b/backend/src/modules/delegation/delegation.controller.ts @@ -0,0 +1,40 @@ +// File: src/modules/delegation/delegation.controller.ts +import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { User } from '../user/entities/user.entity'; +import { DelegationService } from './delegation.service'; +import { CreateDelegationDto } from './dto/create-delegation.dto'; + +@Controller('delegations') +@UseGuards(JwtAuthGuard) +export class DelegationController { + constructor(private readonly delegationService: DelegationService) {} + + /** + * GET /delegations + * ดึง Delegations ของ User ที่ login อยู่ + */ + @Get() + findMyDelegations(@CurrentUser() user: User) { + return this.delegationService.findByDelegator(user.publicId); + } + + /** + * POST /delegations + * สร้าง Delegation ใหม่ (FR-011) + */ + @Post() + create(@CurrentUser() user: User, @Body() dto: CreateDelegationDto) { // eslint-disable-line @typescript-eslint/no-unused-vars + return this.delegationService.create(user.publicId, dto); + } + + /** + * DELETE /delegations/:publicId + * Revoke delegation + */ + @Delete(':publicId') + revoke(@Param('publicId') publicId: string, @CurrentUser() user: User) { + return this.delegationService.revoke(publicId, user.publicId); + } +} diff --git a/backend/src/modules/delegation/delegation.module.ts b/backend/src/modules/delegation/delegation.module.ts new file mode 100644 index 00000000..1ea1feda --- /dev/null +++ b/backend/src/modules/delegation/delegation.module.ts @@ -0,0 +1,20 @@ +// File: src/modules/delegation/delegation.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Delegation } from './entities/delegation.entity'; +import { User } from '../user/entities/user.entity'; +import { DelegationService } from './delegation.service'; +import { DelegationController } from './delegation.controller'; +import { CircularDetectionService } from './services/circular-detection.service'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Delegation, User]), + UserModule, + ], + providers: [DelegationService, CircularDetectionService], + controllers: [DelegationController], + exports: [DelegationService], +}) +export class DelegationModule {} diff --git a/backend/src/modules/delegation/delegation.service.ts b/backend/src/modules/delegation/delegation.service.ts new file mode 100644 index 00000000..4b0cc86a --- /dev/null +++ b/backend/src/modules/delegation/delegation.service.ts @@ -0,0 +1,115 @@ +// File: src/modules/delegation/delegation.service.ts +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Delegation } from './entities/delegation.entity'; +import { User } from '../user/entities/user.entity'; +import { CircularDetectionService } from './services/circular-detection.service'; +import { CreateDelegationDto } from './dto/create-delegation.dto'; + +@Injectable() +export class DelegationService { + private readonly logger = new Logger(DelegationService.name); + + constructor( + @InjectRepository(Delegation) + private readonly delegationRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly circularDetectionService: CircularDetectionService, + ) {} + + /** + * สร้าง Delegation ใหม่ พร้อมตรวจสอบ Circular (FR-011, FR-012) + */ + async create(delegatorPublicId: string, dto: CreateDelegationDto): Promise { + const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } }); + if (!delegator) throw new NotFoundException(`User not found: ${delegatorPublicId}`); + + const delegate = await this.userRepo.findOne({ where: { publicId: dto.delegateUserPublicId } }); + if (!delegate) throw new NotFoundException(`Delegate user not found: ${dto.delegateUserPublicId}`); + + // ตรวจสอบ date range + if (dto.startDate >= dto.endDate) { + throw new BadRequestException('startDate must be before endDate'); + } + + // ตรวจสอบ Circular Delegation (ADR requirement) + const isCircular = await this.circularDetectionService.wouldCreateCircle( + delegator.user_id, + delegate.user_id, + dto.startDate, + ); + + if (isCircular) { + throw new BadRequestException( + 'Circular delegation detected — this would create a delegation loop', + ); + } + + const delegation = this.delegationRepo.create({ + delegatorUserId: delegator.user_id, + delegateUserId: delegate.user_id, + scope: dto.scope, + startDate: dto.startDate, + endDate: dto.endDate, + reason: dto.reason, + isActive: true, + }); + + return this.delegationRepo.save(delegation); + } + + /** + * ดึง Delegations ของ User ทั้งหมด (ในฐานะผู้มอบหมาย) + */ + async findByDelegator(delegatorPublicId: string): Promise { + const user = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } }); + if (!user) throw new NotFoundException(delegatorPublicId); + + return this.delegationRepo.find({ + where: { delegatorUserId: user.user_id }, + relations: ['delegate'], + order: { startDate: 'DESC' }, + }); + } + + /** + * ดึง Active Delegations สำหรับ User ณ วันที่กำหนด (FR-013) + * ใช้ใน ReviewTaskService ก่อน assign task + */ + async findActiveDelegate(userId: number, date: Date = new Date()): Promise { + const delegation = await this.delegationRepo + .createQueryBuilder('d') + .innerJoinAndSelect('d.delegate', 'delegate') + .where('d.delegator_user_id = :userId', { userId }) + .andWhere('d.is_active = 1') + .andWhere('d.start_date <= :date', { date }) + .andWhere('d.end_date >= :date', { date }) + .orderBy('d.created_at', 'DESC') + .getOne(); + + return delegation?.delegate ?? null; + } + + /** + * Revoke delegation ก่อนกำหนด + */ + async revoke(publicId: string, delegatorPublicId: string): Promise { + const delegation = await this.delegationRepo.findOne({ + where: { publicId }, + }); + + if (!delegation) throw new NotFoundException(`Delegation not found: ${publicId}`); + + // ตรวจสอบ ownership + const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } }); + if (!delegator || delegation.delegatorUserId !== delegator.user_id) { + throw new BadRequestException('You can only revoke your own delegations'); + } + + delegation.isActive = false; + delegation.endDate = new Date(); // หยุดทันที + await this.delegationRepo.save(delegation); + } +} diff --git a/backend/src/modules/delegation/dto/create-delegation.dto.ts b/backend/src/modules/delegation/dto/create-delegation.dto.ts new file mode 100644 index 00000000..5a91b641 --- /dev/null +++ b/backend/src/modules/delegation/dto/create-delegation.dto.ts @@ -0,0 +1,31 @@ +// File: src/modules/delegation/dto/create-delegation.dto.ts +import { IsDate, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { Type } from 'class-transformer'; +import { DelegationScope } from '../../common/enums/review.enums'; + +export { DelegationScope }; + +export class CreateDelegationDto { + @IsUUID() + delegateUserPublicId!: string; + + @IsEnum(DelegationScope) + scope!: DelegationScope; + + @IsOptional() + @IsUUID() + projectPublicId?: string; + + @IsDate() + @Type(() => Date) + startDate!: Date; + + @IsDate() + @Type(() => Date) + endDate!: Date; + + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/backend/src/modules/delegation/entities/delegation.entity.ts b/backend/src/modules/delegation/entities/delegation.entity.ts new file mode 100644 index 00000000..e7868ae0 --- /dev/null +++ b/backend/src/modules/delegation/entities/delegation.entity.ts @@ -0,0 +1,67 @@ +// File: src/modules/delegation/entities/delegation.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { User } from '../../user/entities/user.entity'; +import { DelegationScope } from '../../common/enums/review.enums'; + +@Entity('delegations') +export class Delegation extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'delegator_user_id' }) + @Exclude() + delegatorUserId!: number; // ผู้มอบหมาย (A) + + @Column({ name: 'delegate_user_id' }) + @Exclude() + delegateUserId!: number; // ผู้รับมอบหมาย (B) + + @Column({ + type: 'enum', + enum: DelegationScope, + default: DelegationScope.ALL, + }) + scope!: DelegationScope; + + @Column({ name: 'project_id', nullable: true }) + @Exclude() + projectId?: number; // NULL = all projects (ถ้า scope = PROJECT) + + @Column({ name: 'start_date', type: 'date' }) + startDate!: Date; + + @Column({ name: 'end_date', type: 'date' }) + endDate!: Date; + + @Column({ type: 'text', nullable: true }) + reason?: string; + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'delegator_user_id' }) + delegator?: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'delegate_user_id' }) + delegate?: User; +} diff --git a/backend/src/modules/delegation/services/circular-detection.service.ts b/backend/src/modules/delegation/services/circular-detection.service.ts new file mode 100644 index 00000000..9b3ffb4e --- /dev/null +++ b/backend/src/modules/delegation/services/circular-detection.service.ts @@ -0,0 +1,76 @@ +// File: src/modules/delegation/services/circular-detection.service.ts +// ตรวจจับ Circular Delegation (A→B→C→A) ป้องกัน infinite loop (FR-012) +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Delegation } from '../entities/delegation.entity'; + +@Injectable() +export class CircularDetectionService { + constructor( + @InjectRepository(Delegation) + private readonly delegationRepo: Repository, + ) {} + + /** + * ตรวจสอบ Circular Delegation ด้วย Depth-First Search + * ตัวอย่าง: A→B→C→A จะถูกจับได้เมื่อ proposedFrom=A, proposedTo=B + * + * @param proposedFrom - delegatorUserId ที่กำลังจะสร้าง delegation + * @param proposedTo - delegateUserId ที่กำลังจะสร้าง delegation + * @param today - วันที่ตรวจสอบ (default: now) + * @returns true ถ้าจะเกิด circular delegation + */ + async wouldCreateCircle( + proposedFrom: number, + proposedTo: number, + today: Date = new Date(), + ): Promise { + // ถ้า A→B และ proposedFrom=B, proposedTo=A → circular ชัดเจน + if (proposedFrom === proposedTo) return true; + + // ดึง delegations ที่ active ทั้งหมดในช่วงเวลานั้น + const activeDelegations = await this.delegationRepo + .createQueryBuilder('d') + .where('d.is_active = 1') + .andWhere('d.start_date <= :today', { today }) + .andWhere('d.end_date >= :today', { today }) + .select(['d.delegatorUserId', 'd.delegateUserId']) + .getMany(); + + // สร้าง adjacency list: from → [to, ...] + const graph = new Map(); + for (const d of activeDelegations) { + if (!graph.has(d.delegatorUserId)) graph.set(d.delegatorUserId, []); + graph.get(d.delegatorUserId)!.push(d.delegateUserId); + } + + // เพิ่ม edge ที่กำลังจะสร้าง + if (!graph.has(proposedFrom)) graph.set(proposedFrom, []); + graph.get(proposedFrom)!.push(proposedTo); + + // DFS จาก proposedTo เพื่อหา path กลับมาที่ proposedFrom + return this.dfsHasCycle(proposedTo, proposedFrom, graph, new Set()); + } + + private dfsHasCycle( + current: number, + target: number, + graph: Map, + visited: Set, + ): boolean { + if (current === target) return true; + if (visited.has(current)) return false; + + visited.add(current); + + const neighbors = graph.get(current) ?? []; + for (const neighbor of neighbors) { + if (this.dfsHasCycle(neighbor, target, graph, visited)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/modules/distribution/distribution-matrix.service.ts b/backend/src/modules/distribution/distribution-matrix.service.ts new file mode 100644 index 00000000..98c763db --- /dev/null +++ b/backend/src/modules/distribution/distribution-matrix.service.ts @@ -0,0 +1,84 @@ +// File: src/modules/distribution/distribution-matrix.service.ts +// CRUD สำหรับ DistributionMatrix และ Recipients (T053) +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DistributionMatrix } from './entities/distribution-matrix.entity'; +import { DistributionRecipient } from './entities/distribution-recipient.entity'; + +export interface CreateDistributionMatrixDto { + projectId: number; + documentTypeCode: string; + responseCodeFilter?: string[]; +} + +export interface AddRecipientDto { + recipientType: string; + recipientId?: number; + roleCode?: string; + deliveryMethod?: string; + isCc?: boolean; +} + +@Injectable() +export class DistributionMatrixService { + private readonly logger = new Logger(DistributionMatrixService.name); + + constructor( + @InjectRepository(DistributionMatrix) + private readonly matrixRepo: Repository, + @InjectRepository(DistributionRecipient) + private readonly recipientRepo: Repository, + ) {} + + async findByProject(projectId: number): Promise { + return this.matrixRepo.find({ + where: { projectId, isActive: true }, + relations: ['recipients'], + order: { documentTypeCode: 'ASC' }, + }); + } + + async findOneByDocType( + projectId: number, + documentTypeCode: string, + ): Promise { + return this.matrixRepo.findOne({ + where: { projectId, documentTypeCode, isActive: true }, + relations: ['recipients'], + }); + } + + async create(dto: CreateDistributionMatrixDto): Promise { + const matrix = this.matrixRepo.create(dto as Partial); + return this.matrixRepo.save(matrix); + } + + async addRecipient( + matrixPublicId: string, + dto: AddRecipientDto, + ): Promise { + const matrix = await this.matrixRepo.findOne({ where: { publicId: matrixPublicId } }); + if (!matrix) throw new NotFoundException(`Matrix not found: ${matrixPublicId}`); + + const recipient = this.recipientRepo.create({ + matrixId: matrix.id, + ...dto, + } as Partial); + + return this.recipientRepo.save(recipient); + } + + async removeRecipient(recipientPublicId: string): Promise { + const recipient = await this.recipientRepo.findOne({ where: { publicId: recipientPublicId } }); + if (!recipient) throw new NotFoundException(recipientPublicId); + await this.recipientRepo.remove(recipient); + } + + async remove(publicId: string): Promise { + const matrix = await this.matrixRepo.findOne({ where: { publicId } }); + if (!matrix) throw new NotFoundException(publicId); + matrix.isActive = false; + await this.matrixRepo.save(matrix); + } +} diff --git a/backend/src/modules/distribution/distribution.controller.ts b/backend/src/modules/distribution/distribution.controller.ts new file mode 100644 index 00000000..17ee9b64 --- /dev/null +++ b/backend/src/modules/distribution/distribution.controller.ts @@ -0,0 +1,50 @@ +// File: src/modules/distribution/distribution.controller.ts +// Admin endpoints สำหรับจัดการ Distribution Matrix (T058) +import { Controller, Get, Post, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { DistributionMatrixService } from './distribution-matrix.service'; + +class CreateMatrixDto { + projectId!: number; + documentTypeCode!: string; + responseCodeFilter?: string[]; +} + +class AddRecipientDto { + recipientType!: string; + recipientId?: number; + roleCode?: string; + deliveryMethod?: string; + isCc?: boolean; +} + +@Controller('admin/distribution-matrices') +@UseGuards(JwtAuthGuard) +export class DistributionController { + constructor(private readonly matrixService: DistributionMatrixService) {} + + @Get() + findByProject(@Query('projectId') projectId: string) { + return this.matrixService.findByProject(parseInt(projectId, 10)); + } + + @Post() + create(@Body() dto: CreateMatrixDto) { + return this.matrixService.create(dto); + } + + @Post(':publicId/recipients') + addRecipient(@Param('publicId') publicId: string, @Body() dto: AddRecipientDto) { + return this.matrixService.addRecipient(publicId, dto); + } + + @Delete(':publicId/recipients/:recipientPublicId') + removeRecipient(@Param('recipientPublicId') recipientPublicId: string) { + return this.matrixService.removeRecipient(recipientPublicId); + } + + @Delete(':publicId') + remove(@Param('publicId') publicId: string) { + return this.matrixService.remove(publicId); + } +} diff --git a/backend/src/modules/distribution/distribution.module.ts b/backend/src/modules/distribution/distribution.module.ts new file mode 100644 index 00000000..e3f6e349 --- /dev/null +++ b/backend/src/modules/distribution/distribution.module.ts @@ -0,0 +1,32 @@ +// File: src/modules/distribution/distribution.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; +import { DistributionMatrix } from './entities/distribution-matrix.entity'; +import { DistributionRecipient } from './entities/distribution-recipient.entity'; +import { DistributionService } from './distribution.service'; +import { DistributionMatrixService } from './distribution-matrix.service'; +import { DistributionController } from './distribution.controller'; +import { DistributionProcessor } from './processors/distribution.processor'; +import { ApprovalListenerService } from './services/approval-listener.service'; +import { TransmittalCreatorService } from './services/transmittal-creator.service'; +import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([DistributionMatrix, DistributionRecipient]), + BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }), + NotificationModule, + ], + providers: [ + DistributionService, + DistributionMatrixService, + DistributionProcessor, + ApprovalListenerService, + TransmittalCreatorService, + ], + controllers: [DistributionController], + exports: [DistributionService, DistributionMatrixService, ApprovalListenerService], +}) +export class DistributionModule {} diff --git a/backend/src/modules/distribution/distribution.service.ts b/backend/src/modules/distribution/distribution.service.ts new file mode 100644 index 00000000..4f6055d6 --- /dev/null +++ b/backend/src/modules/distribution/distribution.service.ts @@ -0,0 +1,53 @@ +// File: src/modules/distribution/distribution.service.ts +// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants'; + +export interface DistributionJobPayload { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + responseCode: string; + approvedAt: Date; +} + +@Injectable() +export class DistributionService { + private readonly logger = new Logger(DistributionService.name); + + constructor( + @InjectQueue(QUEUE_DISTRIBUTION) + private readonly distributionQueue: Queue, + ) {} + + /** + * Queue distribution job สำหรับ RFA ที่ผ่านการอนุมัติ (FR-018) + */ + async queueDistribution(payload: DistributionJobPayload): Promise { + await this.distributionQueue.add('process-distribution', payload, { + removeOnComplete: true, + removeOnFail: 100, + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + + this.logger.log( + `Distribution queued for RFA ${payload.rfaPublicId} (code: ${payload.responseCode})`, + ); + } + + /** + * ตรวจสอบสถานะ distribution jobs ของ RFA + */ + async getJobStatus(rfaPublicId: string): Promise<{ pending: number; completed: number }> { + const [waiting, active] = await Promise.all([ + this.distributionQueue.getWaitingCount(), + this.distributionQueue.getActiveCount(), + ]); + + return { pending: waiting + active, completed: 0 }; + } +} diff --git a/backend/src/modules/distribution/entities/distribution-matrix.entity.ts b/backend/src/modules/distribution/entities/distribution-matrix.entity.ts new file mode 100644 index 00000000..f5996c41 --- /dev/null +++ b/backend/src/modules/distribution/entities/distribution-matrix.entity.ts @@ -0,0 +1,49 @@ +// File: src/modules/distribution/entities/distribution-matrix.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { DistributionRecipient } from './distribution-recipient.entity'; +import { Project } from '../../project/entities/project.entity'; + +@Entity('distribution_matrices') +export class DistributionMatrix extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id' }) + @Exclude() + projectId!: number; + + @Column({ name: 'document_type_code', length: 20 }) + documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'... + + @Column({ name: 'response_code_filter', type: 'simple-array', nullable: true }) + responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + @OneToMany(() => DistributionRecipient, (r: DistributionRecipient) => r.matrix, { cascade: true }) + recipients?: DistributionRecipient[]; +} diff --git a/backend/src/modules/distribution/entities/distribution-recipient.entity.ts b/backend/src/modules/distribution/entities/distribution-recipient.entity.ts new file mode 100644 index 00000000..183edd34 --- /dev/null +++ b/backend/src/modules/distribution/entities/distribution-recipient.entity.ts @@ -0,0 +1,55 @@ +// File: src/modules/distribution/entities/distribution-recipient.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { DistributionMatrix } from './distribution-matrix.entity'; +import { RecipientType, DeliveryMethod } from '../../common/enums/review.enums'; + +@Entity('distribution_recipients') +export class DistributionRecipient extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'matrix_id' }) + @Exclude() + matrixId!: number; + + @Column({ + type: 'enum', + enum: RecipientType, + }) + recipientType!: RecipientType; + + @Column({ name: 'recipient_id', nullable: true }) + @Exclude() + recipientId?: number; // userId / organizationId / teamId (FK based on type) + + @Column({ name: 'role_code', length: 50, nullable: true }) + roleCode?: string; // 'ALL_QS', 'ALL_SITE_ENG' (when type = ROLE) + + @Column({ + type: 'enum', + enum: DeliveryMethod, + default: DeliveryMethod.BOTH, + }) + deliveryMethod!: DeliveryMethod; + + @Column({ name: 'is_cc', type: 'tinyint', default: 0 }) + isCc!: boolean; // true = CC recipient, false = primary + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @ManyToOne(() => DistributionMatrix, (m: DistributionMatrix) => m.recipients, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matrix_id' }) + matrix!: DistributionMatrix; +} diff --git a/backend/src/modules/distribution/processors/distribution.processor.ts b/backend/src/modules/distribution/processors/distribution.processor.ts new file mode 100644 index 00000000..5081a86d --- /dev/null +++ b/backend/src/modules/distribution/processors/distribution.processor.ts @@ -0,0 +1,39 @@ +// File: src/modules/distribution/processors/distribution.processor.ts +// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008) +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QUEUE_DISTRIBUTION } from '../../common/constants/queue.constants'; +import { DistributionJobPayload } from '../distribution.service'; +import { TransmittalCreatorService } from '../services/transmittal-creator.service'; +import { NotificationService } from '../../notification/notification.service'; + +@Processor(QUEUE_DISTRIBUTION) +export class DistributionProcessor extends WorkerHost { + private readonly logger = new Logger(DistributionProcessor.name); + + constructor( + private readonly transmittalCreator: TransmittalCreatorService, + private readonly notificationService: NotificationService, + ) { + super(); + } + + async process(job: Job): Promise { + const payload = job.data; + + this.logger.log( + `Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`, + ); + + // 1. สร้าง Transmittal records + const result = await this.transmittalCreator.createFromDistribution(payload); + + this.logger.log( + `Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`, + ); + + // 2. แจ้งเตือน submitter + this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`); + } +} diff --git a/backend/src/modules/distribution/services/approval-listener.service.ts b/backend/src/modules/distribution/services/approval-listener.service.ts new file mode 100644 index 00000000..cab391f9 --- /dev/null +++ b/backend/src/modules/distribution/services/approval-listener.service.ts @@ -0,0 +1,56 @@ +// File: src/modules/distribution/services/approval-listener.service.ts +// Strangler Pattern — listens for RFA approval events and triggers distribution (T055) +import { Injectable, Logger } from '@nestjs/common'; +import { DistributionService, DistributionJobPayload } from '../distribution.service'; +import { ConsensusDecision } from '../../common/enums/review.enums'; + +/** + * ApprovalListenerService — ถูกเรียกจาก ReviewTaskService หลัง consensus ถูกตัดสินใจ + * ใช้ Strangler Pattern: ไม่แก้ไข rfaService.approve() โดยตรง + */ +@Injectable() +export class ApprovalListenerService { + private readonly logger = new Logger(ApprovalListenerService.name); + + constructor(private readonly distributionService: DistributionService) {} + + /** + * เรียกเมื่อ consensus ถูกตัดสินใจว่า APPROVED หรือ APPROVED_WITH_COMMENTS (FR-018) + */ + async onConsensusReached(event: { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + responseCode: string; + decision: ConsensusDecision; + approvedAt: Date; + }): Promise { + const shouldDistribute = + event.decision === ConsensusDecision.APPROVED || + event.decision === ConsensusDecision.APPROVED_WITH_COMMENTS || + event.decision === ConsensusDecision.OVERRIDDEN; + + if (!shouldDistribute) { + this.logger.log( + `RFA ${event.rfaPublicId} decision = ${event.decision} — distribution skipped`, + ); + return; + } + + const payload: DistributionJobPayload = { + rfaPublicId: event.rfaPublicId, + rfaRevisionPublicId: event.rfaRevisionPublicId, + projectId: event.projectId, + documentTypeCode: event.documentTypeCode, + responseCode: event.responseCode, + approvedAt: event.approvedAt, + }; + + await this.distributionService.queueDistribution(payload); + + this.logger.log( + `Distribution triggered for RFA ${event.rfaPublicId} (${event.decision})`, + ); + } +} diff --git a/backend/src/modules/distribution/services/transmittal-creator.service.ts b/backend/src/modules/distribution/services/transmittal-creator.service.ts new file mode 100644 index 00000000..79778d89 --- /dev/null +++ b/backend/src/modules/distribution/services/transmittal-creator.service.ts @@ -0,0 +1,69 @@ +// File: src/modules/distribution/services/transmittal-creator.service.ts +// สร้าง Transmittal records จาก Distribution jobs (T057) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DistributionMatrix } from '../entities/distribution-matrix.entity'; + +/** + * TransmittalCreatorService — ใช้ Strangler Pattern ไม่แก้ไข TransmittalService เดิม + * สร้าง Transmittal ผ่าน existing TransmittalService หลัง distribution + */ +@Injectable() +export class TransmittalCreatorService { + private readonly logger = new Logger(TransmittalCreatorService.name); + + constructor( + @InjectRepository(DistributionMatrix) + private readonly matrixRepo: Repository, + ) {} + + /** + * สร้าง Transmittal draft จาก Distribution event (FR-019) + * Note: actual Transmittal creation ผ่าน TransmittalModule — inject ที่ DI level + */ + async createFromDistribution(payload: { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + responseCode: string; + }): Promise<{ transmittalPublicIds: string[] }> { + const matrix = await this.matrixRepo.findOne({ + where: { + projectId: payload.projectId, + documentTypeCode: payload.documentTypeCode, + isActive: true, + }, + relations: ['recipients'], + }); + + if (!matrix || !matrix.recipients || matrix.recipients.length === 0) { + this.logger.log( + `No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}`, + ); + return { transmittalPublicIds: [] }; + } + + // ตรวจสอบ response code filter + if ( + matrix.responseCodeFilter && + matrix.responseCodeFilter.length > 0 && + !matrix.responseCodeFilter.includes(payload.responseCode) + ) { + this.logger.log( + `Response code ${payload.responseCode} not in filter — skipping distribution`, + ); + return { transmittalPublicIds: [] }; + } + + this.logger.log( + `Creating Transmittal for RFA ${payload.rfaPublicId} → ${matrix.recipients.length} recipients`, + ); + + // TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป + // return transmittalService.createDraft({ rfaPublicId, recipients }); + + return { transmittalPublicIds: [] }; + } +} diff --git a/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts b/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts new file mode 100644 index 00000000..cfe8651b --- /dev/null +++ b/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts @@ -0,0 +1,29 @@ +// File: src/modules/reminder/dto/create-reminder-rule.dto.ts +import { IsEnum, IsInt, IsOptional, IsString, IsArray, MaxLength } from 'class-validator'; +import { ReminderType } from '../../common/enums/review.enums'; + +export class CreateReminderRuleDto { + @IsOptional() + @IsInt() + projectId?: number; + + @IsOptional() + @IsString() + @MaxLength(20) + documentTypeCode?: string; + + @IsEnum(ReminderType) + reminderType!: ReminderType; + + @IsInt() + daysBeforeDue!: number; + + @IsOptional() + @IsInt() + escalationLevel?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + notifyRoles?: string[]; +} diff --git a/backend/src/modules/reminder/entities/reminder-rule.entity.ts b/backend/src/modules/reminder/entities/reminder-rule.entity.ts new file mode 100644 index 00000000..bd20b266 --- /dev/null +++ b/backend/src/modules/reminder/entities/reminder-rule.entity.ts @@ -0,0 +1,49 @@ +// File: src/modules/reminder/entities/reminder-rule.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ReminderType } from '../../common/enums/review.enums'; + +@Entity('reminder_rules') +export class ReminderRule extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id', nullable: true }) + @Exclude() + projectId?: number; // NULL = global rule + + @Column({ name: 'document_type_code', length: 20, nullable: true }) + documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types + + @Column({ + type: 'enum', + enum: ReminderType, + }) + reminderType!: ReminderType; + + @Column({ name: 'days_before_due', type: 'int' }) + daysBeforeDue!: number; // บวก = ก่อน due, ลบ = หลัง due (overdue) + + @Column({ name: 'escalation_level', type: 'tinyint', default: 0 }) + escalationLevel!: number; // 0 = reminder, 1 = escalation L1, 2 = escalation L2 + + @Column({ name: 'notify_roles', type: 'simple-array', nullable: true }) + notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER'] + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/reminder/processors/reminder.processor.ts b/backend/src/modules/reminder/processors/reminder.processor.ts new file mode 100644 index 00000000..6bfcaa27 --- /dev/null +++ b/backend/src/modules/reminder/processors/reminder.processor.ts @@ -0,0 +1,74 @@ +// File: src/modules/reminder/processors/reminder.processor.ts +// BullMQ Worker สำหรับประมวลผล Reminder jobs (ADR-008) +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; +import { ReminderType } from '../../common/enums/review.enums'; +import { EscalationService } from '../services/escalation.service'; +import { NotificationService } from '../../notification/notification.service'; +import { ScheduleReminderPayload } from '../services/scheduler.service'; + +@Processor(QUEUE_REMINDERS) +export class ReminderProcessor extends WorkerHost { + private readonly logger = new Logger(ReminderProcessor.name); + + constructor( + private readonly escalationService: EscalationService, + private readonly notificationService: NotificationService, + ) { + super(); + } + + async process(job: Job): Promise { + const { taskPublicId, assigneeUserId, reminderType } = job.data; + + this.logger.log(`Processing reminder job: ${reminderType} for task ${taskPublicId}`); + + switch (reminderType) { + case ReminderType.DUE_SOON: + await this.notificationService.send({ + userId: assigneeUserId, + title: '⏰ Review Task Due Soon', + message: 'Your review task is due in 2 days. Please complete your review.', + type: 'SYSTEM', + entityType: 'review_task', + entityId: taskPublicId as unknown as number, + }); + break; + + case ReminderType.ON_DUE: + await this.notificationService.send({ + userId: assigneeUserId, + title: '🔔 Review Task Due Today', + message: 'Your review task is due today. Please complete it as soon as possible.', + type: 'SYSTEM', + entityType: 'review_task', + entityId: taskPublicId as unknown as number, + }); + break; + + case ReminderType.OVERDUE: + await this.notificationService.send({ + userId: assigneeUserId, + title: '🚨 Review Task Overdue', + message: 'Your review task is overdue. Escalation will occur if not completed.', + type: 'SYSTEM', + entityType: 'review_task', + entityId: taskPublicId as unknown as number, + }); + break; + + case ReminderType.ESCALATION_L1: + await this.escalationService.escalateLevel1(taskPublicId); + break; + + case ReminderType.ESCALATION_L2: + await this.escalationService.escalateLevel2(taskPublicId); + break; + + default: + this.logger.warn(`Unknown reminder type: ${reminderType as string}`); + } + } +} diff --git a/backend/src/modules/reminder/reminder.controller.ts b/backend/src/modules/reminder/reminder.controller.ts new file mode 100644 index 00000000..8603c089 --- /dev/null +++ b/backend/src/modules/reminder/reminder.controller.ts @@ -0,0 +1,37 @@ +// File: src/modules/reminder/reminder.controller.ts +// Admin endpoints สำหรับจัดการ Reminder Rules (T048) +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ReminderService } from './reminder.service'; +import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto'; + +@Controller('admin/reminder-rules') +@UseGuards(JwtAuthGuard) +export class ReminderController { + constructor(private readonly reminderService: ReminderService) {} + + @Get() + findAll(@Query('projectId') projectId?: string) { + return this.reminderService.findAll(projectId ? parseInt(projectId, 10) : undefined); + } + + @Get(':publicId') + findOne(@Param('publicId') publicId: string) { + return this.reminderService.findOne(publicId); + } + + @Post() + create(@Body() dto: CreateReminderRuleDto): Promise { + return this.reminderService.create(dto); + } + + @Patch(':publicId') + update(@Param('publicId') publicId: string, @Body() dto: Partial): Promise { + return this.reminderService.update(publicId, dto); + } + + @Delete(':publicId') + remove(@Param('publicId') publicId: string) { + return this.reminderService.remove(publicId); + } +} diff --git a/backend/src/modules/reminder/reminder.module.ts b/backend/src/modules/reminder/reminder.module.ts new file mode 100644 index 00000000..e529b978 --- /dev/null +++ b/backend/src/modules/reminder/reminder.module.ts @@ -0,0 +1,30 @@ +// File: src/modules/reminder/reminder.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; +import { ReminderRule } from './entities/reminder-rule.entity'; +import { ReviewTask } from '../review-team/entities/review-task.entity'; +import { ReminderService } from './reminder.service'; +import { ReminderController } from './reminder.controller'; +import { SchedulerService } from './services/scheduler.service'; +import { EscalationService } from './services/escalation.service'; +import { ReminderProcessor } from './processors/reminder.processor'; +import { QUEUE_REMINDERS } from '../common/constants/queue.constants'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ReminderRule, ReviewTask]), + BullModule.registerQueue({ name: QUEUE_REMINDERS }), + NotificationModule, + ], + providers: [ + ReminderService, + SchedulerService, + EscalationService, + ReminderProcessor, + ], + controllers: [ReminderController], + exports: [ReminderService, SchedulerService, EscalationService], +}) +export class ReminderModule {} diff --git a/backend/src/modules/reminder/reminder.service.ts b/backend/src/modules/reminder/reminder.service.ts new file mode 100644 index 00000000..20cd05e1 --- /dev/null +++ b/backend/src/modules/reminder/reminder.service.ts @@ -0,0 +1,51 @@ +// File: src/modules/reminder/reminder.service.ts +// ReminderService — CRUD สำหรับ ReminderRule entities (T044) +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReminderRule } from './entities/reminder-rule.entity'; +import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto'; + +export { CreateReminderRuleDto }; + +@Injectable() +export class ReminderService { + private readonly logger = new Logger(ReminderService.name); + + constructor( + @InjectRepository(ReminderRule) + private readonly ruleRepo: Repository, + ) {} + + async findAll(projectId?: number): Promise { + if (projectId !== undefined) { + return this.ruleRepo.find({ + where: [{ projectId }, { projectId: undefined }], + order: { escalationLevel: 'ASC', daysBeforeDue: 'DESC' }, + }); + } + return this.ruleRepo.find({ order: { escalationLevel: 'ASC' } }); + } + + async findOne(publicId: string): Promise { + const rule = await this.ruleRepo.findOne({ where: { publicId } }); + if (!rule) throw new NotFoundException(`ReminderRule not found: ${publicId}`); + return rule; + } + + async create(dto: CreateReminderRuleDto): Promise { + const rule = this.ruleRepo.create(dto as Partial); + return this.ruleRepo.save(rule); + } + + async update(publicId: string, dto: Partial): Promise { + const rule = await this.findOne(publicId); + Object.assign(rule, dto); + return this.ruleRepo.save(rule); + } + + async remove(publicId: string): Promise { + const rule = await this.findOne(publicId); + await this.ruleRepo.remove(rule); + } +} diff --git a/backend/src/modules/reminder/services/escalation.service.ts b/backend/src/modules/reminder/services/escalation.service.ts new file mode 100644 index 00000000..b87c44c2 --- /dev/null +++ b/backend/src/modules/reminder/services/escalation.service.ts @@ -0,0 +1,112 @@ +// File: src/modules/reminder/services/escalation.service.ts +// 2-Level Escalation เมื่อ Review Task เกิน due date (FR-015) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { ReviewTask } from '../../review-team/entities/review-task.entity'; +import { ReviewTaskStatus } from '../../common/enums/review.enums'; +import { NotificationService } from '../../notification/notification.service'; +import { ReminderRule } from '../entities/reminder-rule.entity'; + +@Injectable() +export class EscalationService { + private readonly logger = new Logger(EscalationService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly reviewTaskRepo: Repository, + @InjectRepository(ReminderRule) + private readonly reminderRuleRepo: Repository, + private readonly notificationService: NotificationService, + ) {} + + /** + * Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน + * เรียกเมื่อ task เกิน due date 1 วัน + */ + async escalateLevel1(taskPublicId: string): Promise { + const task = await this.reviewTaskRepo.findOne({ + where: { publicId: taskPublicId }, + relations: ['team', 'assignedToUser', 'discipline'], + }); + + if (!task || task.status === ReviewTaskStatus.COMPLETED) return; + + const daysOverdue = task.dueDate + ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) + : 0; + + if (daysOverdue < 1) return; + + this.logger.log( + `Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`, + ); + + // แจ้ง Team Lead + if (task.assignedToUserId) { + await this.notificationService.send({ + userId: task.assignedToUserId, + title: `⚠ Review Task Overdue (${daysOverdue}d)`, + message: `Your review task is overdue by ${daysOverdue} day(s). Please complete it immediately.`, + type: 'SYSTEM', + entityType: 'review_task', + entityId: task.id, + }); + } + } + + /** + * Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน + * เรียกเมื่อ task เกิน due date 3 วัน + */ + async escalateLevel2(taskPublicId: string): Promise { + const task = await this.reviewTaskRepo.findOne({ + where: { publicId: taskPublicId }, + relations: ['team', 'assignedToUser'], + }); + + if (!task || task.status === ReviewTaskStatus.COMPLETED) return; + + const daysOverdue = task.dueDate + ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) + : 0; + + if (daysOverdue < 3) return; + + this.logger.warn( + `Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM`, + ); + + // TODO: ดึง PM user ID จาก project membership — ใช้ placeholder สำหรับตอนนี้ + this.logger.log(`L2 escalation notification queued for task ${taskPublicId}`); + } + + /** + * สแกน tasks ที่ overdue ทั้งหมด และ escalate ตาม level (cron trigger) + */ + async processOverdueTasks(): Promise { + const now = new Date(); + + const overdueTasks = await this.reviewTaskRepo.find({ + where: { + status: ReviewTaskStatus.IN_PROGRESS, + dueDate: LessThan(now), + }, + select: ['publicId', 'dueDate'], + }); + + this.logger.log(`Processing ${overdueTasks.length} overdue tasks`); + + for (const task of overdueTasks) { + const daysOverdue = task.dueDate + ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) + : 0; + + if (daysOverdue >= 3) { + await this.escalateLevel2(task.publicId); + } else if (daysOverdue >= 1) { + await this.escalateLevel1(task.publicId); + } + } + } +} diff --git a/backend/src/modules/reminder/services/scheduler.service.ts b/backend/src/modules/reminder/services/scheduler.service.ts new file mode 100644 index 00000000..50688cfb --- /dev/null +++ b/backend/src/modules/reminder/services/scheduler.service.ts @@ -0,0 +1,97 @@ +// File: src/modules/reminder/services/scheduler.service.ts +// Schedule reminders เมื่อ RFA submit (FR-013) — เพิ่ม jobs เข้า BullMQ queue +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; +import type { Job } from 'bullmq'; +import { ReminderType } from '../../common/enums/review.enums'; + +export interface ScheduleReminderPayload { + taskPublicId: string; + rfaPublicId: string; + assigneeUserId: number; + dueDate: Date; + reminderType: ReminderType; +} + +@Injectable() +export class SchedulerService { + private readonly logger = new Logger(SchedulerService.name); + + constructor( + @InjectQueue(QUEUE_REMINDERS) + private readonly reminderQueue: Queue, + ) {} + + /** + * Schedule ชุด reminders ให้ Review Task (FR-013) + * เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว + */ + async scheduleForTask(payload: ScheduleReminderPayload): Promise { + const { taskPublicId, dueDate } = payload; + const now = Date.now(); + + const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> = []; + + // 2 วันก่อน due date + const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000; + if (twoDaysBefore > now) { + remindersToSchedule.push({ + type: ReminderType.DUE_SOON, + delayMs: twoDaysBefore - now, + }); + } + + // วัน due date เอง + const onDue = dueDate.getTime(); + if (onDue > now) { + remindersToSchedule.push({ + type: ReminderType.ON_DUE, + delayMs: onDue - now, + }); + } + + // 1 วันหลัง due (Escalation L1) + const oneDayAfter = dueDate.getTime() + 1 * 86_400_000; + remindersToSchedule.push({ + type: ReminderType.ESCALATION_L1, + delayMs: Math.max(oneDayAfter - now, 0), + }); + + // 3 วันหลัง due (Escalation L2) + const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000; + remindersToSchedule.push({ + type: ReminderType.ESCALATION_L2, + delayMs: Math.max(threeDaysAfter - now, 0), + }); + + await Promise.all( + remindersToSchedule.map(({ type, delayMs }) => + this.reminderQueue.add( + 'send-reminder', + { ...payload, reminderType: type }, + { delay: delayMs, removeOnComplete: true, removeOnFail: 100 }, + ), + ), + ); + + this.logger.log( + `Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`, + ); + } + + /** + * ยกเลิก reminders ทั้งหมดของ task (เมื่อ task complete หรือ cancelled) + */ + async cancelForTask(taskPublicId: string): Promise { + const jobs = await this.reminderQueue.getDelayed(); + const taskJobs = jobs.filter((j: Job) => j.data?.taskPublicId === taskPublicId); + + await Promise.all(taskJobs.map((j: Job) => j.remove())); + + this.logger.log( + `Cancelled ${taskJobs.length} reminder jobs for task ${taskPublicId}`, + ); + } +} diff --git a/backend/src/modules/response-code/entities/response-code-rule.entity.ts b/backend/src/modules/response-code/entities/response-code-rule.entity.ts new file mode 100644 index 00000000..9e26fbf6 --- /dev/null +++ b/backend/src/modules/response-code/entities/response-code-rule.entity.ts @@ -0,0 +1,56 @@ +// File: src/modules/response-code/entities/response-code-rule.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ResponseCode } from './response-code.entity'; + +@Entity('response_code_rules') +export class ResponseCodeRule extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id', nullable: true }) + @Exclude() + projectId?: number; // NULL = global default + + @Column({ name: 'document_type_id' }) + @Exclude() + documentTypeId!: number; + + @Column({ name: 'response_code_id' }) + @Exclude() + responseCodeId!: number; + + @Column({ name: 'is_enabled', type: 'tinyint', default: 1 }) + isEnabled!: boolean; + + @Column({ name: 'requires_comments', type: 'tinyint', default: 0 }) + requiresComments!: boolean; + + @Column({ name: 'triggers_notification', type: 'tinyint', default: 0 }) + triggersNotification!: boolean; + + @Column({ name: 'parent_rule_id', nullable: true }) + @Exclude() + parentRuleId?: number; // Inheritance tracking + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => ResponseCode, (code: ResponseCode) => code.rules) + @JoinColumn({ name: 'response_code_id' }) + responseCode!: ResponseCode; +} diff --git a/backend/src/modules/response-code/entities/response-code.entity.ts b/backend/src/modules/response-code/entities/response-code.entity.ts new file mode 100644 index 00000000..4361144d --- /dev/null +++ b/backend/src/modules/response-code/entities/response-code.entity.ts @@ -0,0 +1,61 @@ +// File: src/modules/response-code/entities/response-code.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + OneToMany, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ResponseCodeCategory } from '../../common/enums/review.enums'; +import { ResponseCodeRule } from './response-code-rule.entity'; + +@Entity('response_codes') +export class ResponseCode extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ length: 10 }) + code!: string; // '1A', '1B', '1C', ..., '2', '3', '4' + + @Column({ name: 'sub_status', length: 10, nullable: true }) + subStatus?: string; + + @Column({ + type: 'enum', + enum: ResponseCodeCategory, + }) + category!: ResponseCodeCategory; + + @Column({ name: 'description_th', type: 'text' }) + descriptionTh!: string; + + @Column({ name: 'description_en', type: 'text' }) + descriptionEn!: string; + + @Column({ type: 'json', nullable: true }) + implications?: { + affectsSchedule?: boolean; + affectsCost?: boolean; + requiresContractReview?: boolean; + requiresEiaAmendment?: boolean; + }; + + @Column({ name: 'notify_roles', type: 'simple-array', nullable: true }) + notifyRoles?: string[]; // ['CONTRACT_MANAGER', 'QS_MANAGER'] + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @Column({ name: 'is_system', type: 'tinyint', default: 0 }) + isSystem!: boolean; // System default — ลบไม่ได้ + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @OneToMany(() => ResponseCodeRule, (rule: ResponseCodeRule) => rule.responseCode) + rules?: ResponseCodeRule[]; +} diff --git a/backend/src/modules/response-code/response-code.controller.ts b/backend/src/modules/response-code/response-code.controller.ts new file mode 100644 index 00000000..7c9fba4f --- /dev/null +++ b/backend/src/modules/response-code/response-code.controller.ts @@ -0,0 +1,53 @@ +// File: src/modules/response-code/response-code.controller.ts +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ResponseCodeService } from './response-code.service'; +import { ResponseCodeCategory } from '../common/enums/review.enums'; + +@Controller('response-codes') +@UseGuards(JwtAuthGuard) +export class ResponseCodeController { + constructor(private readonly responseCodeService: ResponseCodeService) {} + + /** + * GET /response-codes + * ดึง Response Codes ทั้งหมด + */ + @Get() + findAll() { + return this.responseCodeService.findAll(); + } + + /** + * GET /response-codes/category/:category + * ดึง Response Codes ตาม Category (FR-006) + */ + @Get('category/:category') + findByCategory(@Param('category') category: ResponseCodeCategory) { + return this.responseCodeService.findByCategory(category); + } + + /** + * GET /response-codes/document-type/:id + * ดึง Response Codes ที่ใช้ได้กับ document type + project + */ + @Get('document-type/:documentTypeId') + findByDocumentType( + @Param('documentTypeId') documentTypeId: string, + @Query('projectId') projectId?: string, + ) { + return this.responseCodeService.findByDocumentType( + Number(documentTypeId), + projectId ? Number(projectId) : undefined, + ); + } + + /** + * GET /response-codes/:publicId + * ดึง Response Code ตาม publicId (ADR-019) + */ + @Get(':publicId') + findOne(@Param('publicId') publicId: string) { + return this.responseCodeService.findByPublicId(publicId); + } +} diff --git a/backend/src/modules/response-code/response-code.module.ts b/backend/src/modules/response-code/response-code.module.ts new file mode 100644 index 00000000..b905d64b --- /dev/null +++ b/backend/src/modules/response-code/response-code.module.ts @@ -0,0 +1,36 @@ +// File: src/modules/response-code/response-code.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ResponseCode } from './entities/response-code.entity'; +import { ResponseCodeRule } from './entities/response-code-rule.entity'; +import { ResponseCodeService } from './response-code.service'; +import { ResponseCodeController } from './response-code.controller'; +import { ImplicationsService } from './services/implications.service'; +import { NotificationTriggerService } from './services/notification-trigger.service'; +import { MatrixManagementService } from './services/matrix-management.service'; +import { InheritanceService } from './services/inheritance.service'; +import { User } from '../user/entities/user.entity'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]), + NotificationModule, + ], + providers: [ + ResponseCodeService, + ImplicationsService, + NotificationTriggerService, + MatrixManagementService, + InheritanceService, + ], + controllers: [ResponseCodeController], + exports: [ + ResponseCodeService, + ImplicationsService, + NotificationTriggerService, + MatrixManagementService, + InheritanceService, + ], +}) +export class ResponseCodeModule {} diff --git a/backend/src/modules/response-code/response-code.service.ts b/backend/src/modules/response-code/response-code.service.ts new file mode 100644 index 00000000..1e8387b2 --- /dev/null +++ b/backend/src/modules/response-code/response-code.service.ts @@ -0,0 +1,94 @@ +// File: src/modules/response-code/response-code.service.ts +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, IsNull } from 'typeorm'; +import { ResponseCode } from './entities/response-code.entity'; +import { ResponseCodeRule } from './entities/response-code-rule.entity'; +import { ResponseCodeCategory } from '../common/enums/review.enums'; + +@Injectable() +export class ResponseCodeService { + private readonly logger = new Logger(ResponseCodeService.name); + + constructor( + @InjectRepository(ResponseCode) + private readonly responseCodeRepo: Repository, + @InjectRepository(ResponseCodeRule) + private readonly responseCodeRuleRepo: Repository, + ) {} + + /** + * ดึง Response Codes ทั้งหมดที่ active + */ + async findAll(): Promise { + return this.responseCodeRepo.find({ + where: { isActive: true }, + order: { category: 'ASC', code: 'ASC' }, + }); + } + + /** + * ดึง Response Codes ตาม Category (FR-006) + * ใช้สำหรับแสดงผลใน Review page ตามประเภทเอกสาร + */ + async findByCategory(category: ResponseCodeCategory): Promise { + return this.responseCodeRepo.find({ + where: { category, isActive: true }, + order: { code: 'ASC' }, + }); + } + + /** + * ดึง Response Codes ที่ใช้ได้กับ document type + project + * รองรับ Global default + Project override (ADR-019 Q1 clarification) + */ + async findByDocumentType( + documentTypeId: number, + projectId?: number, + ): Promise { + // ดึง Rules ระดับ Project (ถ้ามี) หรือ Global default + const rules = await this.responseCodeRuleRepo.find({ + where: [ + { documentTypeId, projectId: projectId ?? IsNull(), isEnabled: true }, + { documentTypeId, projectId: IsNull(), isEnabled: true }, + ], + relations: ['responseCode'], + }); + + // Project rules override global rules + const codeMap = new Map(); + for (const rule of rules) { + if (rule.responseCode?.isActive) { + codeMap.set(rule.responseCodeId, rule.responseCode); + } + } + + return Array.from(codeMap.values()).sort((a, b) => + a.code.localeCompare(b.code), + ); + } + + /** + * ดึง ResponseCode โดย publicId (ADR-019) + */ + async findByPublicId(publicId: string): Promise { + const code = await this.responseCodeRepo.findOne({ + where: { publicId }, + }); + + if (!code) { + throw new NotFoundException(`Response Code not found: ${publicId}`); + } + + return code; + } + + /** + * ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007) + * Code 1C, 1D, 3 → trigger notification + */ + async getNotifyRoles(responseCodePublicId: string): Promise { + const code = await this.findByPublicId(responseCodePublicId); + return code.notifyRoles ?? []; + } +} diff --git a/backend/src/modules/response-code/seeders/response-code.seed.ts b/backend/src/modules/response-code/seeders/response-code.seed.ts new file mode 100644 index 00000000..64ca5eec --- /dev/null +++ b/backend/src/modules/response-code/seeders/response-code.seed.ts @@ -0,0 +1,281 @@ +// File: src/modules/response-code/seeders/response-code.seed.ts +// Seed data สำหรับ Master Approval Matrix — Response Codes มาตรฐาน +// อ้างอิง: specs/1-rfa-approval-refactor/spec.md — Comprehensive Master Approval Matrix + +import { DataSource } from 'typeorm'; +import { ResponseCode } from '../entities/response-code.entity'; +import { ResponseCodeCategory } from '../../common/enums/review.enums'; + +export const responseCodeSeedData = [ + // ─── ENGINEERING Category (Shop Drawing, Method Statement, As-Built) ─────── + { + code: '1A', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่อก่อสร้าง', + descriptionEn: 'Approved for Construction', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1B', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่อก่อสร้าง พร้อมความเห็น (แก้ไขไม่ต้องส่งกลับ)', + descriptionEn: 'Approved for Construction with Comments (No Resubmission Required)', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1C', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติ — มีผลต่อสัญญา/Change Order', + descriptionEn: 'Approved — Contract Implications / Change Order Required', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER'], + isSystem: true, + }, + { + code: '1D', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติทางเลือก — แตกต่างจากแบบสัญญา', + descriptionEn: 'Approved Alternative — Differs from Contract Drawing', + implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'DESIGN_MANAGER'], + isSystem: true, + }, + { + code: '1E', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่อวัตถุประสงค์การออกแบบเท่านั้น', + descriptionEn: 'Approved for Design Purpose Only', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1F', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่ออ้างอิงเท่านั้น', + descriptionEn: 'Approved for Reference Only', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1G', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติพร้อมเงื่อนไข ESG', + descriptionEn: 'Approved with ESG Conditions', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true }, + notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติตามหมายเหตุ — ต้องแก้ไขและส่งกลับเพื่อตรวจสอบ', + descriptionEn: 'Approved as Noted — Revise and Resubmit for Review', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'ปฏิเสธ — ต้องแก้ไขและส่งใหม่', + descriptionEn: 'Rejected — Revise and Resubmit', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: ['PROJECT_MANAGER', 'DESIGN_MANAGER'], + isSystem: true, + }, + { + code: '4', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน', + descriptionEn: 'Not Applicable / Withdrawn', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + + // ─── MATERIAL Category ──────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์เพื่อจัดซื้อ', + descriptionEn: 'Approved for Procurement', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1B', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์ พร้อมความเห็น', + descriptionEn: 'Approved for Procurement with Comments', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1C', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'อนุมัติ — มีผลต่อค่าใช้จ่าย', + descriptionEn: 'Approved — Cost Implications', + implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['QS_MANAGER', 'PROCUREMENT_MANAGER'], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'ส่งข้อมูลเพิ่มเติม', + descriptionEn: 'Provide Additional Information', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามสัญญา', + descriptionEn: 'Rejected — Non-Compliant with Contract', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: ['PROJECT_MANAGER', 'PROCUREMENT_MANAGER'], + isSystem: true, + }, + { + code: '4', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน', + descriptionEn: 'Not Applicable / Withdrawn', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + + // ─── CONTRACT Category ──────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'อนุมัติ — ไม่มีผลต่อสัญญา', + descriptionEn: 'Approved — No Contract Implication', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1C', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'อนุมัติ — ต้องออก Change Order', + descriptionEn: 'Approved — Change Order Required', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER', 'PROJECT_MANAGER'], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'อยู่ระหว่างการพิจารณา — ต้องการข้อมูลเพิ่มเติม', + descriptionEn: 'Under Review — Additional Information Required', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'ปฏิเสธ — ขัดต่อเงื่อนไขสัญญา', + descriptionEn: 'Rejected — Contradicts Contract Terms', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'LEGAL_TEAM', 'PROJECT_MANAGER'], + isSystem: true, + }, + + // ─── TESTING Category ───────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.TESTING, + descriptionTh: 'อนุมัติผลการทดสอบ / ส่งมอบ', + descriptionEn: 'Approved — Test Results / Handover Accepted', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.TESTING, + descriptionTh: 'ผ่านพร้อมข้อบกพร่องเล็กน้อย — ต้องแก้ไขและรายงาน', + descriptionEn: 'Passed with Minor Defects — Rectify and Report', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: ['QA_MANAGER'], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.TESTING, + descriptionTh: 'ไม่ผ่าน — ต้องทดสอบซ้ำ', + descriptionEn: 'Failed — Retest Required', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false }, + notifyRoles: ['PROJECT_MANAGER', 'QA_MANAGER'], + isSystem: true, + }, + + // ─── ESG Category ────────────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.ESG, + descriptionTh: 'อนุมัติ — เป็นไปตามมาตรฐาน ESG', + descriptionEn: 'Approved — ESG Compliant', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1G', + category: ResponseCodeCategory.ESG, + descriptionTh: 'อนุมัติพร้อมเงื่อนไขด้านสิ่งแวดล้อม', + descriptionEn: 'Approved with Environmental Conditions', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true }, + notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.ESG, + descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามข้อกำหนด EIA/ESG', + descriptionEn: 'Rejected — Non-Compliant with EIA/ESG Requirements', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false, requiresEiaAmendment: true }, + notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER', 'PROJECT_MANAGER'], + isSystem: true, + }, +]; + +/** + * Seed Response Codes ลงฐานข้อมูล + * ใช้สำหรับ initial setup และ test environments + */ +export async function seedResponseCodes(dataSource: DataSource): Promise { + const repo = dataSource.getRepository(ResponseCode); + + for (const data of responseCodeSeedData) { + const exists = await repo.findOne({ + where: { code: data.code, category: data.category as ResponseCodeCategory }, + }); + + if (!exists) { + const entity = repo.create({ + code: data.code, + category: data.category as ResponseCodeCategory, + descriptionTh: data.descriptionTh, + descriptionEn: data.descriptionEn, + implications: data.implications, + notifyRoles: data.notifyRoles, + isSystem: data.isSystem, + isActive: true, + }); + await repo.save(entity); + } + } +} diff --git a/backend/src/modules/response-code/services/implications.service.ts b/backend/src/modules/response-code/services/implications.service.ts new file mode 100644 index 00000000..2c7754b9 --- /dev/null +++ b/backend/src/modules/response-code/services/implications.service.ts @@ -0,0 +1,110 @@ +// File: src/modules/response-code/services/implications.service.ts +// ประเมินผลกระทบของ Response Code ที่เลือก (FR-007) +import { Injectable, Logger } from '@nestjs/common'; +import { ResponseCode } from '../entities/response-code.entity'; + +export interface CodeImplicationResult { + affectsSchedule: boolean; + affectsCost: boolean; + requiresContractReview: boolean; + requiresEiaAmendment: boolean; + notifyRoles: string[]; + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + actionRequired: string[]; +} + +@Injectable() +export class ImplicationsService { + private readonly logger = new Logger(ImplicationsService.name); + + /** + * ประเมินผลกระทบของ Response Code (FR-007) + * Code 1C, 1D, 3 → Critical → trigger notifications + */ + evaluate(responseCode: ResponseCode): CodeImplicationResult { + const implications = responseCode.implications ?? {}; + const notifyRoles = responseCode.notifyRoles ?? []; + + const affectsSchedule = implications.affectsSchedule ?? false; + const affectsCost = implications.affectsCost ?? false; + const requiresContractReview = implications.requiresContractReview ?? false; + const requiresEiaAmendment = implications.requiresEiaAmendment ?? false; + + // กำหนด severity ตามน้ำหนักผลกระทบ + const severity = this.calculateSeverity( + responseCode.code, + affectsSchedule, + affectsCost, + requiresContractReview, + ); + + const actionRequired = this.buildActionList( + responseCode.code, + requiresContractReview, + requiresEiaAmendment, + affectsCost, + ); + + return { + affectsSchedule, + affectsCost, + requiresContractReview, + requiresEiaAmendment, + notifyRoles, + severity, + actionRequired, + }; + } + + private calculateSeverity( + code: string, + affectsSchedule: boolean, + affectsCost: boolean, + requiresContractReview: boolean, + ): CodeImplicationResult['severity'] { + // Code 3 (Rejected) = CRITICAL เสมอ + if (code === '3') return 'CRITICAL'; + + // Code 1C (Contract Implications) หรือ 1D (Alternative) = HIGH + if (code === '1C' || code === '1D') return 'HIGH'; + + // มีผลต่อทั้ง schedule และ cost + if (affectsSchedule && affectsCost) return 'HIGH'; + + // มีผลต่ออย่างใดอย่างหนึ่ง + if (requiresContractReview || affectsSchedule || affectsCost) return 'MEDIUM'; + + return 'LOW'; + } + + private buildActionList( + code: string, + requiresContractReview: boolean, + requiresEiaAmendment: boolean, + affectsCost: boolean, + ): string[] { + const actions: string[] = []; + + if (code === '3') { + actions.push('Document rejected — originator must revise and resubmit'); + } + + if (requiresContractReview) { + actions.push('Contract review required — notify Contract Manager'); + } + + if (affectsCost) { + actions.push('Cost impact assessment required — notify QS Manager'); + } + + if (requiresEiaAmendment) { + actions.push('EIA amendment may be required — notify EIA Officer'); + } + + if (code === '2') { + actions.push('Minor comments — originator to revise and resubmit'); + } + + return actions; + } +} diff --git a/backend/src/modules/response-code/services/inheritance.service.ts b/backend/src/modules/response-code/services/inheritance.service.ts new file mode 100644 index 00000000..06374f6d --- /dev/null +++ b/backend/src/modules/response-code/services/inheritance.service.ts @@ -0,0 +1,121 @@ +// File: src/modules/response-code/services/inheritance.service.ts +// Resolves project-level overrides inheriting from global defaults (T062, FR-021) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResponseCodeRule } from '../entities/response-code-rule.entity'; + +export interface ResolvedMatrix { + responseCodeId: number; + responseCodePublicId: string; + documentTypeId: number; + isEnabled: boolean; + requiresComments: boolean; + triggersNotification: boolean; + isOverridden: boolean; // true = project-specific rule overrides global + parentRuleId?: number; +} + +@Injectable() +export class InheritanceService { + private readonly logger = new Logger(InheritanceService.name); + + constructor( + @InjectRepository(ResponseCodeRule) + private readonly ruleRepo: Repository, + ) {} + + /** + * ดึง rules สำหรับ document type โดย merge global + project overrides (FR-021) + * Project rule ชนะ global rule ของ responseCode เดียวกัน + * + * @param documentTypeId - document type ที่ต้องการ + * @param projectId - project ID (NULL = global only) + */ + async resolveMatrix( + documentTypeId: number, + projectId?: number, + ): Promise { + // ดึง global rules (projectId IS NULL) + const globalRules = await this.ruleRepo.find({ + where: { documentTypeId, projectId: undefined }, + relations: ['responseCode'], + }); + + if (!projectId) { + return globalRules.map((r) => ({ + responseCodeId: r.responseCodeId, + responseCodePublicId: r.responseCode.publicId, + documentTypeId: r.documentTypeId, + isEnabled: r.isEnabled, + requiresComments: r.requiresComments, + triggersNotification: r.triggersNotification, + isOverridden: false, + parentRuleId: undefined, + })); + } + + // ดึง project-specific overrides + const projectRules = await this.ruleRepo.find({ + where: { documentTypeId, projectId }, + relations: ['responseCode'], + }); + + // Build map: responseCodeId → project rule + const projectRuleMap = new Map( + projectRules.map((r) => [r.responseCodeId, r]), + ); + + // Merge: project overrides global + const merged: ResolvedMatrix[] = globalRules.map((global) => { + const override = projectRuleMap.get(global.responseCodeId); + if (override) { + return { + responseCodeId: override.responseCodeId, + responseCodePublicId: override.responseCode.publicId, + documentTypeId: override.documentTypeId, + isEnabled: override.isEnabled, + requiresComments: override.requiresComments, + triggersNotification: override.triggersNotification, + isOverridden: true, + parentRuleId: global.id, + }; + } + return { + responseCodeId: global.responseCodeId, + responseCodePublicId: global.responseCode.publicId, + documentTypeId: global.documentTypeId, + isEnabled: global.isEnabled, + requiresComments: global.requiresComments, + triggersNotification: global.triggersNotification, + isOverridden: false, + parentRuleId: undefined, + }; + }); + + // เพิ่ม project-only rules (ไม่มี global parent) + for (const projectRule of projectRules) { + const alreadyMerged = globalRules.some( + (g) => g.responseCodeId === projectRule.responseCodeId, + ); + if (!alreadyMerged) { + merged.push({ + responseCodeId: projectRule.responseCodeId, + responseCodePublicId: projectRule.responseCode.publicId, + documentTypeId: projectRule.documentTypeId, + isEnabled: projectRule.isEnabled, + requiresComments: projectRule.requiresComments, + triggersNotification: projectRule.triggersNotification, + isOverridden: true, + parentRuleId: undefined, + }); + } + } + + this.logger.debug( + `Resolved ${merged.length} rules for docType=${documentTypeId}, project=${projectId}`, + ); + + return merged; + } +} diff --git a/backend/src/modules/response-code/services/matrix-management.service.ts b/backend/src/modules/response-code/services/matrix-management.service.ts new file mode 100644 index 00000000..598b62ba --- /dev/null +++ b/backend/src/modules/response-code/services/matrix-management.service.ts @@ -0,0 +1,103 @@ +// File: src/modules/response-code/services/matrix-management.service.ts +// CRUD สำหรับ ResponseCodeRule (global + project overrides) (T061, FR-022) +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResponseCodeRule } from '../entities/response-code-rule.entity'; +import { ResponseCode } from '../entities/response-code.entity'; + +export interface UpsertRuleDto { + documentTypeId: number; + responseCodePublicId: string; + projectId?: number; + isEnabled: boolean; + requiresComments?: boolean; + triggersNotification?: boolean; +} + +@Injectable() +export class MatrixManagementService { + private readonly logger = new Logger(MatrixManagementService.name); + + constructor( + @InjectRepository(ResponseCodeRule) + private readonly ruleRepo: Repository, + @InjectRepository(ResponseCode) + private readonly codeRepo: Repository, + ) {} + + /** + * Upsert a rule — สร้างใหม่หรือแก้ไข existing rule (FR-022) + */ + async upsertRule(dto: UpsertRuleDto): Promise { + const code = await this.codeRepo.findOne({ + where: { publicId: dto.responseCodePublicId }, + }); + + if (!code) { + throw new NotFoundException(`ResponseCode not found: ${dto.responseCodePublicId}`); + } + + if (code.isSystem && !dto.isEnabled) { + throw new BadRequestException('Cannot disable a system response code'); + } + + const existing = await this.ruleRepo.findOne({ + where: { + documentTypeId: dto.documentTypeId, + responseCodeId: code.id, + projectId: dto.projectId ?? undefined, + }, + }); + + if (existing) { + existing.isEnabled = dto.isEnabled; + existing.requiresComments = dto.requiresComments ?? existing.requiresComments; + existing.triggersNotification = dto.triggersNotification ?? existing.triggersNotification; + return this.ruleRepo.save(existing); + } + + const rule = this.ruleRepo.create({ + documentTypeId: dto.documentTypeId, + responseCodeId: code.id, + projectId: dto.projectId, + isEnabled: dto.isEnabled, + requiresComments: dto.requiresComments ?? false, + triggersNotification: dto.triggersNotification ?? false, + } as Partial); + + return this.ruleRepo.save(rule); + } + + /** + * ดึง rules ทั้งหมดของ document type (global + project) + */ + async getRulesByDocType( + documentTypeId: number, + projectId?: number, + ): Promise { + const where: Record = { documentTypeId }; + if (projectId !== undefined) { + where['projectId'] = projectId; + } else { + where['projectId'] = undefined; // global only + } + + return this.ruleRepo.find({ + where, + relations: ['responseCode'], + }); + } + + /** + * ลบ project override (หวนกลับใช้ global default) + */ + async deleteProjectOverride(rulePublicId: string): Promise { + const rule = await this.ruleRepo.findOne({ where: { publicId: rulePublicId } }); + if (!rule) throw new NotFoundException(rulePublicId); + if (!rule.projectId) { + throw new BadRequestException('Cannot delete a global rule — disable it instead'); + } + await this.ruleRepo.remove(rule); + } +} diff --git a/backend/src/modules/response-code/services/notification-trigger.service.ts b/backend/src/modules/response-code/services/notification-trigger.service.ts new file mode 100644 index 00000000..6d9db5f2 --- /dev/null +++ b/backend/src/modules/response-code/services/notification-trigger.service.ts @@ -0,0 +1,86 @@ +// File: src/modules/response-code/services/notification-trigger.service.ts +// ส่งการแจ้งเตือนอัตโนมัติเมื่อ Response Code มีผลกระทบสำคัญ (FR-007) +// Code 1C (Change Order), 1D (Alternative), 3 (Rejected) → notify ฝ่ายที่เกี่ยวข้อง +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResponseCode } from '../entities/response-code.entity'; +import { NotificationService } from '../../notification/notification.service'; +import { User } from '../../user/entities/user.entity'; +import { ImplicationsService } from './implications.service'; + +@Injectable() +export class NotificationTriggerService { + private readonly logger = new Logger(NotificationTriggerService.name); + + constructor( + @InjectRepository(ResponseCode) + private readonly responseCodeRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly notificationService: NotificationService, + private readonly implicationsService: ImplicationsService, + ) {} + + /** + * Trigger notifications เมื่อ Review Task เสร็จสิ้นด้วย Code ที่มีผลกระทบ (FR-007) + * เรียกจาก ReviewTaskService หลังจาก completeReview + */ + async triggerIfRequired( + responseCodePublicId: string, + rfaPublicId: string, + documentNumber: string, + reviewerUserId: number, + ): Promise { + const responseCode = await this.responseCodeRepo.findOne({ + where: { publicId: responseCodePublicId }, + }); + + if (!responseCode) { + this.logger.warn(`Response code not found for notification trigger: ${responseCodePublicId}`); + return; + } + + const evaluation = this.implicationsService.evaluate(responseCode); + + // ถ้า severity ต่ำ ไม่ต้อง notify เพิ่ม + if (evaluation.severity === 'LOW') return; + + const notifyRoles = evaluation.notifyRoles; + + if (notifyRoles.length === 0) return; + + // หา Users ที่มี role ที่ต้องการแจ้งเตือน + const targetUsers = await this.userRepo + .createQueryBuilder('user') + .where('user.role IN (:...roles)', { roles: notifyRoles }) + .andWhere('user.is_active = 1') + .getMany(); + + const codeLabel = responseCode.code; + const title = `Response Code ${codeLabel} — Action Required`; + const message = [ + `Document: ${documentNumber}`, + `Response Code: ${codeLabel} — ${responseCode.descriptionEn}`, + ...evaluation.actionRequired, + ].join('\n'); + + // ส่งแจ้งเตือนแบบ parallel (ADR-008: ผ่าน BullMQ) + await Promise.all( + targetUsers.map((user: User) => + this.notificationService.send({ + userId: user.user_id, + title, + message, + type: 'SYSTEM', + entityType: 'rfa', + entityId: rfaPublicId as unknown as number, + }), + ), + ); + + this.logger.log( + `Triggered ${notifyRoles.length} role notifications for code ${codeLabel} on document ${documentNumber}`, + ); + } +} diff --git a/backend/src/modules/review-team/dto/shared/review-team.dto.ts b/backend/src/modules/review-team/dto/shared/review-team.dto.ts new file mode 100644 index 00000000..a745eb08 --- /dev/null +++ b/backend/src/modules/review-team/dto/shared/review-team.dto.ts @@ -0,0 +1,179 @@ +// File: src/modules/review-team/dto/shared/review-team.dto.ts +// Shared DTOs สำหรับ Review Team และ Review Task APIs + +import { + IsString, + IsOptional, + IsBoolean, + IsEnum, + IsArray, + IsUUID, + IsDateString, + IsInt, + IsPositive, + MinLength, + MaxLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ReviewTaskStatus, + ReviewTeamMemberRole, + DelegationScope, +} from '../../../common/enums/review.enums'; + +// ─── Review Team DTOs ────────────────────────────────────────────────────── + +export class CreateReviewTeamDto { + @IsString() + @MinLength(1) + @MaxLength(100) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @IsUUID() + projectPublicId!: string; // ADR-019: รับ publicId เสมอ + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultForRfaTypes?: string[]; // ['SDW', 'DDW', 'ADW'] +} + +export class UpdateReviewTeamDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultForRfaTypes?: string[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class AddTeamMemberDto { + @IsUUID() + userPublicId!: string; // ADR-019 + + @IsUUID() + disciplinePublicId!: string; // ADR-019 + + @IsEnum(ReviewTeamMemberRole) + role!: ReviewTeamMemberRole; + + @IsOptional() + @IsInt() + @IsPositive() + priorityOrder?: number; +} + +export class SearchReviewTeamDto { + @IsOptional() + @IsUUID() + projectPublicId?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + isActive?: boolean; + + @IsOptional() + @IsString() + search?: string; +} + +// ─── Review Task DTOs ────────────────────────────────────────────────────── + +export class CompleteReviewTaskDto { + @IsUUID() + responseCodePublicId!: string; // ADR-019: รับ publicId + + @IsOptional() + @IsString() + comments?: string; + + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + attachmentPublicIds?: string[]; +} + +export class UpdateReviewTaskStatusDto { + @IsEnum(ReviewTaskStatus) + status!: ReviewTaskStatus; + + @IsOptional() + @IsString() + reason?: string; +} + +export class SearchReviewTaskDto { + @IsOptional() + @IsUUID() + rfaRevisionPublicId?: string; + + @IsOptional() + @IsEnum(ReviewTaskStatus) + status?: ReviewTaskStatus; + + @IsOptional() + @IsUUID() + assignedToUserPublicId?: string; + + @IsOptional() + @IsDateString() + dueDateFrom?: string; + + @IsOptional() + @IsDateString() + dueDateTo?: string; +} + +// ─── Delegation DTOs ─────────────────────────────────────────────────────── + +export class CreateDelegationDto { + @IsUUID() + delegateePublicId!: string; // ADR-019 + + @IsDateString() + startDate!: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsEnum(DelegationScope) + scope!: DelegationScope; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + documentTypes?: string[]; + + @IsOptional() + @IsString() + reason?: string; +} + +// ─── Veto Override DTO ───────────────────────────────────────────────────── + +export class VetoOverrideDto { + @IsString() + @MinLength(10) + @MaxLength(1000) + justification!: string; // PM ต้องระบุเหตุผลที่ชัดเจน (min 10 chars) +} diff --git a/backend/src/modules/review-team/entities/review-task.entity.ts b/backend/src/modules/review-team/entities/review-task.entity.ts new file mode 100644 index 00000000..8a95d770 --- /dev/null +++ b/backend/src/modules/review-team/entities/review-task.entity.ts @@ -0,0 +1,98 @@ +// File: src/modules/review-team/entities/review-task.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + VersionColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ReviewTeam } from './review-team.entity'; +import { ResponseCode } from '../../response-code/entities/response-code.entity'; +import { User } from '../../user/entities/user.entity'; +import { Discipline } from '../../master/entities/discipline.entity'; +import { ReviewTaskStatus } from '../../common/enums/review.enums'; + +@Entity('review_tasks') +export class ReviewTask extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'rfa_revision_id' }) + @Exclude() + rfaRevisionId!: number; + + @Column({ name: 'team_id' }) + @Exclude() + teamId!: number; + + @Column({ name: 'discipline_id' }) + @Exclude() + disciplineId!: number; + + @Column({ name: 'assigned_to_user_id', nullable: true }) + @Exclude() + assignedToUserId?: number; // NULL = auto-assign ตาม discipline + + @Column({ + type: 'enum', + enum: ReviewTaskStatus, + default: ReviewTaskStatus.PENDING, + }) + status!: ReviewTaskStatus; + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate?: Date; + + @Column({ name: 'response_code_id', nullable: true }) + @Exclude() + responseCodeId?: number; + + @Column({ type: 'text', nullable: true }) + comments?: string; + + @Column({ type: 'json', nullable: true }) + attachments?: string[]; // Array ของ attachment publicIds + + @Column({ name: 'delegated_from_user_id', nullable: true }) + @Exclude() + delegatedFromUserId?: number; // ติดตาม delegation chain + + @Column({ name: 'completed_at', type: 'timestamp', nullable: true }) + completedAt?: Date; + + @VersionColumn() + version!: number; // Optimistic locking (ADR-002) + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => ReviewTeam) + @JoinColumn({ name: 'team_id' }) + team?: ReviewTeam; + + @ManyToOne(() => Discipline) + @JoinColumn({ name: 'discipline_id' }) + discipline?: Discipline; + + @ManyToOne(() => User) + @JoinColumn({ name: 'assigned_to_user_id' }) + assignedToUser?: User; + + @ManyToOne(() => ResponseCode) + @JoinColumn({ name: 'response_code_id' }) + responseCode?: ResponseCode; + + @ManyToOne(() => User) + @JoinColumn({ name: 'delegated_from_user_id' }) + delegatedFromUser?: User; +} diff --git a/backend/src/modules/review-team/entities/review-team-member.entity.ts b/backend/src/modules/review-team/entities/review-team-member.entity.ts new file mode 100644 index 00000000..73cea13f --- /dev/null +++ b/backend/src/modules/review-team/entities/review-team-member.entity.ts @@ -0,0 +1,60 @@ +// File: src/modules/review-team/entities/review-team-member.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { User } from '../../user/entities/user.entity'; +import { Discipline } from '../../master/entities/discipline.entity'; +import { ReviewTeam } from './review-team.entity'; +import { ReviewTeamMemberRole } from '../../common/enums/review.enums'; + +@Entity('review_team_members') +export class ReviewTeamMember extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'team_id' }) + @Exclude() + teamId!: number; + + @Column({ name: 'user_id' }) + @Exclude() + userId!: number; + + @Column({ name: 'discipline_id' }) + @Exclude() + disciplineId!: number; + + @Column({ + type: 'enum', + enum: ReviewTeamMemberRole, + default: ReviewTeamMemberRole.REVIEWER, + }) + role!: ReviewTeamMemberRole; + + @Column({ name: 'priority_order', default: 0 }) + priorityOrder!: number; // สำหรับ fallback sequential assignment + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'team_id' }) + team!: ReviewTeam; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user?: User; + + @ManyToOne(() => Discipline) + @JoinColumn({ name: 'discipline_id' }) + discipline?: Discipline; +} diff --git a/backend/src/modules/review-team/entities/review-team.entity.ts b/backend/src/modules/review-team/entities/review-team.entity.ts new file mode 100644 index 00000000..0db7f66b --- /dev/null +++ b/backend/src/modules/review-team/entities/review-team.entity.ts @@ -0,0 +1,52 @@ +// File: src/modules/review-team/entities/review-team.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Project } from '../../project/entities/project.entity'; +import { ReviewTeamMember } from './review-team-member.entity'; + +@Entity('review_teams') +export class ReviewTeam extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id' }) + @Exclude() + projectId!: number; + + @Column({ length: 100 }) + name!: string; + + @Column({ length: 255, nullable: true }) + description?: string; + + @Column({ name: 'default_for_rfa_types', type: 'simple-array', nullable: true }) + defaultForRfaTypes?: string[]; // Auto-assign ให้ RFA type เช่น ['SDW','DDW'] + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + @OneToMany(() => ReviewTeamMember, (member: ReviewTeamMember) => member.team, { cascade: true }) + members?: ReviewTeamMember[]; +} diff --git a/backend/src/modules/review-team/review-task.service.ts b/backend/src/modules/review-team/review-task.service.ts new file mode 100644 index 00000000..09cef191 --- /dev/null +++ b/backend/src/modules/review-team/review-task.service.ts @@ -0,0 +1,179 @@ +// File: src/modules/review-team/review-task.service.ts +import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReviewTask } from './entities/review-task.entity'; +import { ResponseCode } from '../response-code/entities/response-code.entity'; +import { + CompleteReviewTaskDto, + SearchReviewTaskDto, +} from './dto/shared/review-team.dto'; +import { ReviewTaskStatus } from '../common/enums/review.enums'; +import { validateTaskCompletionRequirements } from '../../common/validators/review-validators'; + +@Injectable() +export class ReviewTaskService { + private readonly logger = new Logger(ReviewTaskService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly reviewTaskRepo: Repository, + @InjectRepository(ResponseCode) + private readonly responseCodeRepo: Repository, + ) {} + + /** + * ดึง Tasks ทั้งหมดของ RFA Revision (internal use) + */ + async findByRevisionId(rfaRevisionId: number): Promise { + return this.reviewTaskRepo.find({ where: { rfaRevisionId } }); + } + + /** + * ค้นหา Review Tasks ตาม filter (FR-004) + */ + async findAll(dto: SearchReviewTaskDto): Promise { + const qb = this.reviewTaskRepo + .createQueryBuilder('task') + .leftJoinAndSelect('task.discipline', 'discipline') + .leftJoinAndSelect('task.assignedToUser', 'user') + .leftJoinAndSelect('task.responseCode', 'responseCode') + .leftJoinAndSelect('task.team', 'team'); + + if (dto.rfaRevisionPublicId) { + qb.innerJoin('rfa_revisions', 'rev', 'rev.id = task.rfa_revision_id') + .where('rev.uuid = :uuid', { uuid: dto.rfaRevisionPublicId }); + } + + if (dto.status) { + qb.andWhere('task.status = :status', { status: dto.status }); + } + + if (dto.assignedToUserPublicId) { + qb.andWhere('user.uuid = :userUuid', { userUuid: dto.assignedToUserPublicId }); + } + + if (dto.dueDateFrom) { + qb.andWhere('task.due_date >= :from', { from: dto.dueDateFrom }); + } + + if (dto.dueDateTo) { + qb.andWhere('task.due_date <= :to', { to: dto.dueDateTo }); + } + + return qb.orderBy('task.created_at', 'ASC').getMany(); + } + + /** + * ดึง Review Task ตาม publicId (ADR-019) + */ + async findByPublicId(publicId: string): Promise { + const task = await this.reviewTaskRepo.findOne({ + where: { publicId }, + relations: ['discipline', 'assignedToUser', 'responseCode', 'team'], + }); + + if (!task) { + throw new NotFoundException(`Review Task not found: ${publicId}`); + } + + return task; + } + + /** + * ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004) + */ + async getAggregateStatus(rfaRevisionId: number): Promise<{ + total: number; + completed: number; + pending: number; + summary: string; + }> { + const tasks = await this.reviewTaskRepo.find({ where: { rfaRevisionId } }); + + const total = tasks.length; + const completed = tasks.filter( + (t: ReviewTask) => + t.status === ReviewTaskStatus.COMPLETED || t.status === ReviewTaskStatus.CANCELLED, + ).length; + const pending = total - completed; + + return { + total, + completed, + pending, + summary: `${completed} of ${total} Disciplines Reviewed`, + }; + } + + /** + * เริ่มตรวจสอบ Review Task (เปลี่ยน status จาก PENDING → IN_PROGRESS) + */ + async startReview(publicId: string): Promise { + const task = await this.findByPublicId(publicId); + + if (task.status !== ReviewTaskStatus.PENDING) { + throw new BadRequestException( + `Cannot start review: task is already ${task.status}`, + ); + } + + task.status = ReviewTaskStatus.IN_PROGRESS; + return this.reviewTaskRepo.save(task); + } + + /** + * บันทึกผลการตรวจสอบ (FR-009, T069) + * ใช้ Optimistic Locking (@VersionColumn) ป้องกัน race condition (ADR-002) + */ + async completeReview(publicId: string, dto: CompleteReviewTaskDto): Promise { + const task = await this.findByPublicId(publicId); + + if ( + task.status === ReviewTaskStatus.COMPLETED || + task.status === ReviewTaskStatus.CANCELLED + ) { + throw new BadRequestException( + `Cannot complete review: task is already ${task.status}`, + ); + } + + // ตรวจสอบ Response Code (ADR-019) + const responseCode = await this.responseCodeRepo.findOne({ + where: { publicId: dto.responseCodePublicId }, + }); + + if (!responseCode) { + throw new NotFoundException( + `Response Code not found: ${dto.responseCodePublicId}`, + ); + } + + // Validate completion requirements (T073) + validateTaskCompletionRequirements( + ReviewTaskStatus.COMPLETED, + responseCode.id, + false, // requiresComments checked at controller level via ResponseCodeRule + dto.comments, + ); + + task.status = ReviewTaskStatus.COMPLETED; + task.responseCodeId = responseCode.id; + task.comments = dto.comments; + task.attachments = dto.attachmentPublicIds; + task.completedAt = new Date(); + + try { + // TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002) + return await this.reviewTaskRepo.save(task); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes('OptimisticLock') || errorMessage.includes('version')) { + throw new ConflictException( + 'Review task was modified concurrently. Please refresh and try again.', + ); + } + throw err; + } + } +} diff --git a/backend/src/modules/review-team/review-team.controller.ts b/backend/src/modules/review-team/review-team.controller.ts new file mode 100644 index 00000000..fcaa50c4 --- /dev/null +++ b/backend/src/modules/review-team/review-team.controller.ts @@ -0,0 +1,92 @@ +// File: src/modules/review-team/review-team.controller.ts +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ReviewTeamService } from './review-team.service'; +import { + CreateReviewTeamDto, + UpdateReviewTeamDto, + AddTeamMemberDto, + SearchReviewTeamDto, +} from './dto/shared/review-team.dto'; + +@Controller('review-teams') +@UseGuards(JwtAuthGuard) +export class ReviewTeamController { + constructor(private readonly reviewTeamService: ReviewTeamService) {} + + /** + * GET /review-teams + * ดึงรายการ Review Teams ตาม project + */ + @Get() + findAll(@Query() dto: SearchReviewTeamDto) { + return this.reviewTeamService.findAll(dto); + } + + /** + * GET /review-teams/:publicId + * ดึง Review Team เดียว (ADR-019) + */ + @Get(':publicId') + findOne(@Param('publicId') publicId: string) { + return this.reviewTeamService.findByPublicId(publicId); + } + + /** + * POST /review-teams + * สร้าง Review Team ใหม่ + */ + @Post() + create(@Body() dto: CreateReviewTeamDto) { + return this.reviewTeamService.create(dto); + } + + /** + * PATCH /review-teams/:publicId + * อัปเดต Review Team + */ + @Patch(':publicId') + update(@Param('publicId') publicId: string, @Body() dto: UpdateReviewTeamDto) { + return this.reviewTeamService.update(publicId, dto); + } + + /** + * POST /review-teams/:publicId/members + * เพิ่มสมาชิก + */ + @Post(':publicId/members') + addMember(@Param('publicId') teamPublicId: string, @Body() dto: AddTeamMemberDto) { + return this.reviewTeamService.addMember(teamPublicId, dto); + } + + /** + * DELETE /review-teams/:publicId/members/:memberPublicId + * ลบสมาชิก + */ + @Delete(':publicId/members/:memberPublicId') + removeMember( + @Param('publicId') teamPublicId: string, + @Param('memberPublicId') memberPublicId: string, + ) { + return this.reviewTeamService.removeMember(teamPublicId, memberPublicId); + } + + /** + * DELETE /review-teams/:publicId + * Deactivate Review Team (soft delete) + */ + @Delete(':publicId') + deactivate(@Param('publicId') publicId: string) { + return this.reviewTeamService.deactivate(publicId); + } +} diff --git a/backend/src/modules/review-team/review-team.module.ts b/backend/src/modules/review-team/review-team.module.ts new file mode 100644 index 00000000..33b76bb1 --- /dev/null +++ b/backend/src/modules/review-team/review-team.module.ts @@ -0,0 +1,66 @@ +// File: src/modules/review-team/review-team.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; + +// Entities +import { ReviewTeam } from './entities/review-team.entity'; +import { ReviewTeamMember } from './entities/review-team-member.entity'; +import { ReviewTask } from './entities/review-task.entity'; + +// External entities needed for resolution +import { User } from '../user/entities/user.entity'; +import { Discipline } from '../master/entities/discipline.entity'; + +// Services +import { ReviewTeamService } from './review-team.service'; +import { ReviewTaskService } from './review-task.service'; +import { TaskCreationService } from './services/task-creation.service'; +import { AggregateStatusService } from './services/aggregate-status.service'; +import { ConsensusService } from './services/consensus.service'; +import { VetoOverrideService } from './services/veto-override.service'; + +// Controllers +import { ReviewTeamController } from './review-team.controller'; + +// Modules +import { ResponseCodeModule } from '../response-code/response-code.module'; +import { NotificationModule } from '../notification/notification.module'; +import { UserModule } from '../user/user.module'; +import { DistributionModule } from '../distribution/distribution.module'; + +// Queue constants +import { QUEUE_REMINDERS, QUEUE_VETO_NOTIFICATIONS } from '../common/constants/queue.constants'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ReviewTeam, ReviewTeamMember, ReviewTask, User, Discipline]), + BullModule.registerQueue( + { name: QUEUE_REMINDERS }, + { name: QUEUE_VETO_NOTIFICATIONS }, + ), + ResponseCodeModule, + NotificationModule, + UserModule, + DistributionModule, + ], + providers: [ + ReviewTeamService, + ReviewTaskService, + TaskCreationService, + AggregateStatusService, + ConsensusService, + VetoOverrideService, + ], + controllers: [ReviewTeamController], + exports: [ + ReviewTeamService, + ReviewTaskService, + TaskCreationService, + AggregateStatusService, + ConsensusService, + VetoOverrideService, + TypeOrmModule, + ], +}) +export class ReviewTeamModule {} diff --git a/backend/src/modules/review-team/review-team.service.ts b/backend/src/modules/review-team/review-team.service.ts new file mode 100644 index 00000000..b05b23a4 --- /dev/null +++ b/backend/src/modules/review-team/review-team.service.ts @@ -0,0 +1,188 @@ +// File: src/modules/review-team/review-team.service.ts +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReviewTeam } from './entities/review-team.entity'; +import { ReviewTeamMember } from './entities/review-team-member.entity'; +import { User } from '../user/entities/user.entity'; +import { Discipline } from '../master/entities/discipline.entity'; +import { + CreateReviewTeamDto, + UpdateReviewTeamDto, + AddTeamMemberDto, + SearchReviewTeamDto, +} from './dto/shared/review-team.dto'; + +@Injectable() +export class ReviewTeamService { + private readonly logger = new Logger(ReviewTeamService.name); + + constructor( + @InjectRepository(ReviewTeam) + private readonly teamRepo: Repository, + @InjectRepository(ReviewTeamMember) + private readonly memberRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(Discipline) + private readonly disciplineRepo: Repository, + ) {} + + /** + * ดึง Review Teams ตาม project (FR-001) + */ + async findAll(dto: SearchReviewTeamDto): Promise { + const qb = this.teamRepo + .createQueryBuilder('team') + .leftJoinAndSelect('team.members', 'member') + .leftJoinAndSelect('member.user', 'user') + .leftJoinAndSelect('member.discipline', 'discipline'); + + if (dto.projectPublicId) { + qb.innerJoin('team.project', 'project').where('project.uuid = :uuid', { + uuid: dto.projectPublicId, + }); + } + + if (dto.isActive !== undefined) { + qb.andWhere('team.is_active = :isActive', { isActive: dto.isActive }); + } + + if (dto.search) { + qb.andWhere('team.name LIKE :search', { search: `%${dto.search}%` }); + } + + return qb.orderBy('team.created_at', 'DESC').getMany(); + } + + /** + * ดึง Review Team เดียวตาม publicId (ADR-019) + */ + async findByPublicId(publicId: string): Promise { + const team = await this.teamRepo.findOne({ + where: { publicId }, + relations: ['members', 'members.user', 'members.discipline', 'project'], + }); + + if (!team) { + throw new NotFoundException(`Review Team not found: ${publicId}`); + } + + return team; + } + + /** + * ดึง Teams ที่เป็น Default สำหรับ RFA type นั้นๆ (FR-002) + */ + async findDefaultForRfaType(rfaTypeCode: string, projectId: number): Promise { + const teams = await this.teamRepo.find({ + where: { projectId, isActive: true }, + relations: ['members'], + }); + + return teams.filter( + (t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false, + ); + } + + /** + * สร้าง Review Team ใหม่ + */ + async create(dto: CreateReviewTeamDto): Promise { + // ตรวจสอบว่า project มีอยู่จริง (via publicId) + const project = await this.teamRepo.manager.getRepository('projects').findOne({ + where: { uuid: dto.projectPublicId } as Record, + }); + + if (!project) { + throw new NotFoundException(`Project not found: ${dto.projectPublicId}`); + } + + const team = this.teamRepo.create({ + name: dto.name, + description: dto.description, + projectId: (project as { id: number }).id, + defaultForRfaTypes: dto.defaultForRfaTypes, + isActive: true, + }); + + return this.teamRepo.save(team); + } + + /** + * อัปเดต Review Team + */ + async update(publicId: string, dto: UpdateReviewTeamDto): Promise { + const team = await this.findByPublicId(publicId); + + if (dto.name !== undefined) team.name = dto.name; + if (dto.description !== undefined) team.description = dto.description; + if (dto.defaultForRfaTypes !== undefined) team.defaultForRfaTypes = dto.defaultForRfaTypes; + if (dto.isActive !== undefined) team.isActive = dto.isActive; + + return this.teamRepo.save(team); + } + + /** + * เพิ่มสมาชิกใน Review Team (FR-001) + */ + async addMember(teamPublicId: string, dto: AddTeamMemberDto): Promise { + const team = await this.findByPublicId(teamPublicId); + + // ตรวจสอบ User + const user = await this.userRepo.findOne({ where: { publicId: dto.userPublicId } }); + if (!user) throw new NotFoundException(`User not found: ${dto.userPublicId}`); + + // ตรวจสอบ Discipline + const discipline = await this.disciplineRepo.findOne({ + where: { id: Number(dto.disciplinePublicId) }, + }); + if (!discipline) throw new NotFoundException(`Discipline not found: ${dto.disciplinePublicId}`); + + // ตรวจสอบซ้ำ + const existing = await this.memberRepo.findOne({ + where: { teamId: team.id, userId: user.user_id, disciplineId: discipline.id }, + }); + + if (existing) { + throw new BadRequestException( + `User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`, + ); + } + + const member = this.memberRepo.create({ + teamId: team.id, + userId: user.user_id, + disciplineId: discipline.id, + role: dto.role, + priorityOrder: dto.priorityOrder ?? 0, + }); + + return this.memberRepo.save(member); + } + + /** + * ลบสมาชิกออกจาก Review Team + */ + async removeMember(teamPublicId: string, memberPublicId: string): Promise { + const team = await this.findByPublicId(teamPublicId); + const member = await this.memberRepo.findOne({ + where: { publicId: memberPublicId, teamId: team.id }, + }); + + if (!member) { + throw new NotFoundException(`Member not found: ${memberPublicId}`); + } + + await this.memberRepo.remove(member); + } + + /** + * ลบ Review Team (soft delete ด้วย isActive = false) + */ + async deactivate(publicId: string): Promise { + const team = await this.findByPublicId(publicId); + team.isActive = false; + await this.teamRepo.save(team); + } +} diff --git a/backend/src/modules/review-team/services/aggregate-status.service.ts b/backend/src/modules/review-team/services/aggregate-status.service.ts new file mode 100644 index 00000000..7851bb0c --- /dev/null +++ b/backend/src/modules/review-team/services/aggregate-status.service.ts @@ -0,0 +1,107 @@ +// File: src/modules/review-team/services/aggregate-status.service.ts +// คำนวณสถานะรวมของ Review Tasks ภายใน RFA Revision (T067, FR-009) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReviewTask } from '../entities/review-task.entity'; +import { ReviewTaskStatus, ConsensusDecision } from '../../common/enums/review.enums'; + +export interface AggregateStatus { + total: number; + completed: number; + pending: number; + inProgress: number; + delegated: number; + expired: number; + completionPct: number; + isAllComplete: boolean; + hasExpired: boolean; +} + +@Injectable() +export class AggregateStatusService { + private readonly logger = new Logger(AggregateStatusService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository, + ) {} + + /** + * คำนวณสถานะรวมของทุก Review Tasks ใน RFA Revision (FR-009) + */ + async getForRevision(rfaRevisionId: number): Promise { + const tasks = await this.taskRepo.find({ + where: { rfaRevisionId }, + select: ['id', 'status'], + }); + + const counts = { + total: tasks.length, + completed: 0, + pending: 0, + inProgress: 0, + delegated: 0, + expired: 0, + }; + + for (const task of tasks) { + switch (task.status) { + case ReviewTaskStatus.COMPLETED: counts.completed++; break; + case ReviewTaskStatus.PENDING: counts.pending++; break; + case ReviewTaskStatus.IN_PROGRESS: counts.inProgress++; break; + case ReviewTaskStatus.DELEGATED: counts.delegated++; break; + case ReviewTaskStatus.EXPIRED: counts.expired++; break; + default: break; + } + } + + const completionPct = + counts.total > 0 + ? Math.round((counts.completed / counts.total) * 100) + : 0; + + return { + ...counts, + completionPct, + isAllComplete: counts.total > 0 && counts.completed === counts.total, + hasExpired: counts.expired > 0, + }; + } + + /** + * ตรวจสอบว่า RFA Revision พร้อมสำหรับ consensus หรือยัง (FR-010) + */ + async isReadyForConsensus(rfaRevisionId: number): Promise { + const status = await this.getForRevision(rfaRevisionId); + return status.isAllComplete; + } + + /** + * Determine consensus based on response codes of completed tasks (FR-010) + */ + async evaluateConsensus(rfaRevisionId: number): Promise { + const tasks = await this.taskRepo.find({ + where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED }, + relations: ['responseCode'], + }); + + if (tasks.length === 0) return ConsensusDecision.PENDING; + + // Veto check: any Code 3 = REJECTED + const hasVeto = tasks.some((t) => t.responseCode?.code === '3'); + if (hasVeto) return ConsensusDecision.REJECTED; + + // All approved: Code 1A or 1B = APPROVED + const allApproved = tasks.every((t) => + ['1A', '1B'].includes(t.responseCode?.code ?? ''), + ); + if (allApproved) return ConsensusDecision.APPROVED; + + // Any Code 2 = APPROVED_WITH_COMMENTS + const hasComments = tasks.some((t) => t.responseCode?.code === '2'); + if (hasComments) return ConsensusDecision.APPROVED_WITH_COMMENTS; + + return ConsensusDecision.APPROVED_WITH_COMMENTS; + } +} diff --git a/backend/src/modules/review-team/services/consensus.service.ts b/backend/src/modules/review-team/services/consensus.service.ts new file mode 100644 index 00000000..e1035996 --- /dev/null +++ b/backend/src/modules/review-team/services/consensus.service.ts @@ -0,0 +1,95 @@ +// File: src/modules/review-team/services/consensus.service.ts +// Evaluate parallel review consensus และ trigger distribution (T068, FR-010) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReviewTask } from '../entities/review-task.entity'; +import { AggregateStatusService } from './aggregate-status.service'; +import { ApprovalListenerService } from '../../distribution/services/approval-listener.service'; +import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums'; + +export interface ConsensusResult { + decision: ConsensusDecision; + completedTasks: number; + totalTasks: number; + triggeredDistribution: boolean; +} + +@Injectable() +export class ConsensusService { + private readonly logger = new Logger(ConsensusService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository, + private readonly aggregateStatusService: AggregateStatusService, + private readonly approvalListenerService: ApprovalListenerService, + ) {} + + /** + * เรียกหลัง task complete — ตรวจสอบ consensus และ trigger distribution (FR-010) + */ + async evaluateAfterTaskComplete( + rfaRevisionId: number, + context: { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + }, + ): Promise { + const isReady = await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId); + + const status = await this.aggregateStatusService.getForRevision(rfaRevisionId); + + if (!isReady) { + this.logger.debug( + `Revision ${rfaRevisionId}: ${status.completed}/${status.total} tasks done — not ready for consensus`, + ); + return { + decision: ConsensusDecision.PENDING, + completedTasks: status.completed, + totalTasks: status.total, + triggeredDistribution: false, + }; + } + + const decision = await this.aggregateStatusService.evaluateConsensus(rfaRevisionId); + + this.logger.log( + `Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`, + ); + + let triggeredDistribution = false; + if ( + decision === ConsensusDecision.APPROVED || + decision === ConsensusDecision.APPROVED_WITH_COMMENTS + ) { + // ดึง response code ที่ predominant + const completedTasks = await this.taskRepo.find({ + where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED }, + relations: ['responseCode'], + order: { completedAt: 'DESC' }, + take: 1, + }); + + const responseCode = completedTasks[0]?.responseCode?.code ?? '1A'; + + await this.approvalListenerService.onConsensusReached({ + ...context, + responseCode, + decision, + approvedAt: new Date(), + }); + + triggeredDistribution = true; + } + + return { + decision, + completedTasks: status.completed, + totalTasks: status.total, + triggeredDistribution, + }; + } +} diff --git a/backend/src/modules/review-team/services/task-creation.service.ts b/backend/src/modules/review-team/services/task-creation.service.ts new file mode 100644 index 00000000..e1e84a6b --- /dev/null +++ b/backend/src/modules/review-team/services/task-creation.service.ts @@ -0,0 +1,112 @@ +// File: src/modules/review-team/services/task-creation.service.ts +// Strangler Pattern: แยก logic การสร้าง Parallel Review Tasks ออกจาก rfa.service.ts +// เรียกใช้หลังจาก RFA Submit สำเร็จ (T017 integration) + +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, EntityManager } from 'typeorm'; +import { ReviewTeam } from '../entities/review-team.entity'; +import { ReviewTeamMember } from '../entities/review-team-member.entity'; +import { ReviewTask } from '../entities/review-task.entity'; +import { ReviewTaskStatus } from '../../common/enums/review.enums'; + +@Injectable() +export class TaskCreationService { + private readonly logger = new Logger(TaskCreationService.name); + + constructor( + @InjectRepository(ReviewTeam) + private readonly reviewTeamRepo: Repository, + @InjectRepository(ReviewTeamMember) + private readonly memberRepo: Repository, + @InjectRepository(ReviewTask) + private readonly reviewTaskRepo: Repository, + ) {} + + /** + * สร้าง Parallel Review Tasks สำหรับแต่ละ Discipline ใน Review Team (FR-003) + * เรียกใช้ภายใน Transaction ของ rfa.service.ts submit method + * + * @param rfaRevisionId - Internal ID ของ RFA Revision + * @param reviewTeamPublicId - publicId ของ Review Team (ADR-019) + * @param dueDate - กำหนดเวลาตรวจสอบ + * @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม) + */ + async createParallelTasks( + rfaRevisionId: number, + reviewTeamPublicId: string, + dueDate: Date, + manager: EntityManager, + ): Promise { + // ดึง ReviewTeam พร้อม members + const team = await this.reviewTeamRepo.findOne({ + where: { publicId: reviewTeamPublicId }, + relations: ['members'], + }); + + if (!team || !team.isActive) { + this.logger.warn( + `ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`, + ); + return []; + } + + const members = team.members ?? []; + + if (members.length === 0) { + this.logger.warn( + `ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`, + ); + return []; + } + + // กลุ่ม members ตาม disciplineId (แต่ละ Discipline ต้องการเพียง 1 Task) + const disciplineMap = new Map(); + for (const member of members) { + // LEAD มี priority สูงสุด ถ้ามีหลายคนใน Discipline เดียวกัน + const existing = disciplineMap.get(member.disciplineId); + if (!existing || member.role === 'LEAD') { + disciplineMap.set(member.disciplineId, member); + } + } + + const tasks: ReviewTask[] = []; + + // สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel) + for (const [disciplineId, leadMember] of disciplineMap) { + const task = manager.create(ReviewTask, { + rfaRevisionId, + teamId: team.id, + disciplineId, + assignedToUserId: leadMember.userId, + status: ReviewTaskStatus.PENDING, + dueDate, + }); + const saved = await manager.save(ReviewTask, task); + tasks.push(saved); + } + + this.logger.log( + `Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`, + ); + + return tasks; + } + + /** + * ตรวจสอบว่า RFA Revision มี Review Tasks ครบทุก Discipline แล้วหรือยัง + */ + async areAllTasksCompleted(rfaRevisionId: number): Promise { + const tasks = await this.reviewTaskRepo.find({ + where: { rfaRevisionId }, + }); + + if (tasks.length === 0) return false; + + return tasks.every( + (t: ReviewTask) => + t.status === ReviewTaskStatus.COMPLETED || + t.status === ReviewTaskStatus.CANCELLED, + ); + } +} diff --git a/backend/src/modules/review-team/services/veto-override.service.ts b/backend/src/modules/review-team/services/veto-override.service.ts new file mode 100644 index 00000000..b87d28a7 --- /dev/null +++ b/backend/src/modules/review-team/services/veto-override.service.ts @@ -0,0 +1,70 @@ +// File: src/modules/review-team/services/veto-override.service.ts +// PM Veto Override — บังคับผ่าน RFA Revision แม้มี Code 3 (T068.5) +import { Injectable, Logger, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { ReviewTask } from '../entities/review-task.entity'; +import { ApprovalListenerService } from '../../distribution/services/approval-listener.service'; +import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums'; + +export interface VetoOverrideDto { + rfaRevisionId: number; + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + overrideReason: string; + overriddenByUserId: number; +} + +@Injectable() +export class VetoOverrideService { + private readonly logger = new Logger(VetoOverrideService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository, + private readonly approvalListenerService: ApprovalListenerService, + private readonly dataSource: DataSource, + ) {} + + /** + * PM Override: บังคับ APPROVED แม้ว่าจะมี Code 3 rejection (FR-012) + * ต้องมี justification reason และ audit trail + */ + async executeOverride(dto: VetoOverrideDto): Promise<{ decision: ConsensusDecision }> { + const tasks = await this.taskRepo.find({ + where: { rfaRevisionId: dto.rfaRevisionId }, + relations: ['responseCode'], + }); + + if (tasks.length === 0) { + throw new NotFoundException(`No review tasks found for revision ${dto.rfaRevisionId}`); + } + + const hasVeto = tasks.some((t) => t.responseCode?.code === '3'); + if (!hasVeto) { + throw new ForbiddenException('No Code 3 veto found — override not needed'); + } + + if (!dto.overrideReason || dto.overrideReason.trim().length < 10) { + throw new ForbiddenException('Override reason must be at least 10 characters'); + } + + this.logger.warn( + `PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`, + ); + + await this.approvalListenerService.onConsensusReached({ + rfaPublicId: dto.rfaPublicId, + rfaRevisionPublicId: dto.rfaRevisionPublicId, + projectId: dto.projectId, + documentTypeCode: dto.documentTypeCode, + responseCode: '1A', + decision: ConsensusDecision.OVERRIDDEN, + approvedAt: new Date(), + }); + + return { decision: ConsensusDecision.OVERRIDDEN }; + } +} diff --git a/backend/src/modules/rfa/dto/submit-rfa.dto.ts b/backend/src/modules/rfa/dto/submit-rfa.dto.ts index 392c5d90..e8374b9f 100644 --- a/backend/src/modules/rfa/dto/submit-rfa.dto.ts +++ b/backend/src/modules/rfa/dto/submit-rfa.dto.ts @@ -1,6 +1,6 @@ // File: src/modules/rfa/dto/submit-rfa.dto.ts import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsNotEmpty } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; export class SubmitRfaDto { @ApiProperty({ @@ -10,4 +10,13 @@ export class SubmitRfaDto { @IsInt() @IsNotEmpty() templateId!: number; + + @ApiProperty({ + description: 'publicId ของ Review Team สำหรับ Parallel Review (ADR-019)', + example: '019505a1-7c3e-7000-8000-abc123def456', + required: false, + }) + @IsOptional() + @IsUUID() + reviewTeamPublicId?: string; } diff --git a/backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts b/backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts new file mode 100644 index 00000000..b0355aa6 --- /dev/null +++ b/backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts @@ -0,0 +1,85 @@ +// File: src/modules/workflow-engine/dsl/parallel-gateway.handler.ts +// Parallel Gateway DSL handler สำหรับ RFA parallel review (T066, ADR-001) +// Strangler Pattern: ขยาย WorkflowEngine โดยไม่แก้ไข core DSL +import { Injectable, Logger } from '@nestjs/common'; + +export interface ParallelGatewayStep { + type: 'parallel_gateway'; + id: string; + branches: ParallelBranch[]; + completionStrategy: 'ALL' | 'MAJORITY' | 'ANY'; + onComplete: string; // next step ID +} + +export interface ParallelBranch { + id: string; + assigneeType: 'DISCIPLINE' | 'USER' | 'TEAM'; + assigneeId: string; // publicId + steps: string[]; // step IDs within this branch +} + +export interface GatewayExecutionContext { + rfaRevisionPublicId: string; + completedBranches: Set; + totalBranches: number; +} + +@Injectable() +export class ParallelGatewayHandler { + private readonly logger = new Logger(ParallelGatewayHandler.name); + + /** + * ตรวจสอบว่า gateway สามารถเดินหน้าได้หรือยัง ตาม completionStrategy (FR-008) + */ + canAdvance(step: ParallelGatewayStep, ctx: GatewayExecutionContext): boolean { + const { completedBranches, totalBranches } = ctx; + + switch (step.completionStrategy) { + case 'ALL': + return completedBranches.size === totalBranches; + + case 'MAJORITY': + return completedBranches.size > Math.floor(totalBranches / 2); + + case 'ANY': + return completedBranches.size >= 1; + + default: + this.logger.warn(`Unknown completion strategy: ${step.completionStrategy as string}`); + return false; + } + } + + /** + * สร้าง execution context จาก gateway definition + */ + createContext( + rfaRevisionPublicId: string, + step: ParallelGatewayStep, + ): GatewayExecutionContext { + return { + rfaRevisionPublicId, + completedBranches: new Set(), + totalBranches: step.branches.length, + }; + } + + /** + * Mark a branch complete and check if gateway can advance + */ + markBranchComplete( + ctx: GatewayExecutionContext, + branchId: string, + step: ParallelGatewayStep, + ): { canAdvance: boolean; completedCount: number } { + ctx.completedBranches.add(branchId); + + const canAdvance = this.canAdvance(step, ctx); + + this.logger.log( + `Branch ${branchId} complete. ${ctx.completedBranches.size}/${ctx.totalBranches} — canAdvance: ${canAdvance}`, + ); + + return { canAdvance, completedCount: ctx.completedBranches.size }; + } +} diff --git a/backend/tests/e2e/rfa-workflow.e2e-spec.ts b/backend/tests/e2e/rfa-workflow.e2e-spec.ts new file mode 100644 index 00000000..b4e7ae79 --- /dev/null +++ b/backend/tests/e2e/rfa-workflow.e2e-spec.ts @@ -0,0 +1,38 @@ +// File: tests/e2e/rfa-workflow.e2e-spec.ts +// E2E test ครอบคลุม RFA Approval Refactor full workflow (T077) +// TODO: ต้องมี test database + seeded data สำหรับ E2E run จริง + +/** + * E2E Workflow Coverage: + * 1. RFA submit → Review Tasks created (parallel) + * 2. All reviewers complete → Consensus evaluated + * 3. Consensus APPROVED → Distribution queued + * 4. Distribution processed → Transmittal created + * 5. Veto (Code 3) → PM override → force APPROVED + * 6. Reminder sent when task overdue + * 7. Delegation: delegate completes task on behalf + */ + +describe('RFA Approval Workflow (E2E)', () => { + // TODO: Bootstrap NestJS test app + seed test data + + describe('Phase 1-3: Submit → Parallel Review → Consensus', () => { + it.todo('should create parallel review tasks on RFA submit'); + it.todo('should evaluate APPROVED consensus when all Code 1A'); + it.todo('should evaluate REJECTED consensus when any Code 3'); + it.todo('should allow PM override of Code 3 veto'); + }); + + describe('Phase 4-5: Delegation → Reminder', () => { + it.todo('should delegate review task to another user'); + it.todo('should block circular delegation'); + it.todo('should send reminder when task is overdue'); + it.todo('should escalate to L2 after 3 days overdue'); + }); + + describe('Phase 6-7: Distribution', () => { + it.todo('should queue distribution after APPROVED consensus'); + it.todo('should create Transmittal records from distribution matrix'); + it.todo('should skip distribution for REJECTED'); + }); +}); diff --git a/backend/tests/integration/review-team/parallel-review.spec.ts b/backend/tests/integration/review-team/parallel-review.spec.ts new file mode 100644 index 00000000..9727ac68 --- /dev/null +++ b/backend/tests/integration/review-team/parallel-review.spec.ts @@ -0,0 +1,49 @@ +// File: tests/integration/review-team/parallel-review.spec.ts +// Integration tests สำหรับ Parallel Review consensus flow (T076) +// TODO: ขยาย test suite เมื่อ test database พร้อม (Sprint ถัดไป) + +import { ConsensusDecision } from '../../../src/modules/common/enums/review.enums'; + +describe('Parallel Review Consensus (Integration)', () => { + describe('Consensus evaluation', () => { + it('should return APPROVED when all tasks have Code 1A', () => { + const codes = ['1A', '1A', '1A']; + const hasVeto = codes.some((c) => c === '3'); + const allApproved = codes.every((c) => ['1A', '1B'].includes(c)); + + const decision = hasVeto + ? ConsensusDecision.REJECTED + : allApproved + ? ConsensusDecision.APPROVED + : ConsensusDecision.APPROVED_WITH_COMMENTS; + + expect(decision).toBe(ConsensusDecision.APPROVED); + }); + + it('should return REJECTED when any task has Code 3', () => { + const codes = ['1A', '3', '2']; + const hasVeto = codes.some((c) => c === '3'); + + const decision = hasVeto ? ConsensusDecision.REJECTED : ConsensusDecision.APPROVED; + + expect(decision).toBe(ConsensusDecision.REJECTED); + }); + + it('should return APPROVED_WITH_COMMENTS when mix of 1A and 2', () => { + const codes = ['1A', '2', '1B']; + const hasVeto = codes.some((c) => c === '3'); + const allApproved = codes.every((c) => ['1A', '1B'].includes(c)); + const hasComments = codes.some((c) => c === '2'); + + const decision = hasVeto + ? ConsensusDecision.REJECTED + : allApproved + ? ConsensusDecision.APPROVED + : hasComments + ? ConsensusDecision.APPROVED_WITH_COMMENTS + : ConsensusDecision.PENDING; + + expect(decision).toBe(ConsensusDecision.APPROVED_WITH_COMMENTS); + }); + }); +}); diff --git a/backend/tests/unit/delegation/circular-detection.service.spec.ts b/backend/tests/unit/delegation/circular-detection.service.spec.ts new file mode 100644 index 00000000..d461e0a6 --- /dev/null +++ b/backend/tests/unit/delegation/circular-detection.service.spec.ts @@ -0,0 +1,69 @@ +// File: tests/unit/delegation/circular-detection.service.spec.ts +// Unit tests สำหรับ CircularDetectionService — ป้องกัน delegation loops (T075) +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CircularDetectionService } from '../../../src/modules/delegation/services/circular-detection.service'; +import { Delegation } from '../../../src/modules/delegation/entities/delegation.entity'; + +const mockDelegationRepo = { + find: jest.fn(), +}; + +describe('CircularDetectionService', () => { + let service: CircularDetectionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CircularDetectionService, + { provide: getRepositoryToken(Delegation), useValue: mockDelegationRepo }, + ], + }).compile(); + + service = module.get(CircularDetectionService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('wouldCreateCircle', () => { + it('should return false when no delegations exist', async () => { + mockDelegationRepo.find.mockResolvedValue([]); + const result = await service.wouldCreateCircle(1, 2); + expect(result).toBe(false); + }); + + it('should detect direct circular delegation A→B when B→A exists', async () => { + // B (id=2) delegates to A (id=1) + mockDelegationRepo.find.mockResolvedValue([ + { delegatorId: 2, delegateId: 1 }, + ]); + // Now trying to add A→B — would create cycle + const result = await service.wouldCreateCircle(1, 2); + expect(result).toBe(true); + }); + + it('should detect indirect cycle A→B→C when trying C→A', async () => { + // A→B and B→C already exist + mockDelegationRepo.find.mockResolvedValue([ + { delegatorId: 1, delegateId: 2 }, + { delegatorId: 2, delegateId: 3 }, + ]); + // Now trying C→A — would create A→B→C→A cycle + const result = await service.wouldCreateCircle(3, 1); + expect(result).toBe(true); + }); + + it('should return false for non-circular delegations', async () => { + // A→B and B→C — adding D→A is fine + mockDelegationRepo.find.mockResolvedValue([ + { delegatorId: 1, delegateId: 2 }, + { delegatorId: 2, delegateId: 3 }, + ]); + const result = await service.wouldCreateCircle(4, 1); + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/tests/unit/response-code/response-code.service.spec.ts b/backend/tests/unit/response-code/response-code.service.spec.ts new file mode 100644 index 00000000..fb114668 --- /dev/null +++ b/backend/tests/unit/response-code/response-code.service.spec.ts @@ -0,0 +1,67 @@ +// File: tests/unit/response-code/response-code.service.spec.ts +// Unit tests สำหรับ ResponseCodeService (T074) +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ResponseCodeService } from '../../../src/modules/response-code/response-code.service'; +import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity'; +import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity'; +import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums'; + +const mockCode: Partial = { + id: 1, + publicId: 'test-uuid-1', + code: '1A', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'ผ่าน — ไม่มีเงื่อนไข', + descriptionEn: 'Approved — No Comments', + isActive: true, + isSystem: true, +}; + +const mockCodeRepo = { + find: jest.fn().mockResolvedValue([mockCode]), + findOne: jest.fn().mockResolvedValue(mockCode), +}; + +const mockRuleRepo = { + find: jest.fn().mockResolvedValue([]), +}; + +describe('ResponseCodeService', () => { + let service: ResponseCodeService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ResponseCodeService, + { provide: getRepositoryToken(ResponseCode), useValue: mockCodeRepo }, + { provide: getRepositoryToken(ResponseCodeRule), useValue: mockRuleRepo }, + ], + }).compile(); + + service = module.get(ResponseCodeService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findByCategory', () => { + it('should return codes filtered by category', async () => { + const result = await service.findByCategory(ResponseCodeCategory.ENGINEERING); + expect(mockCodeRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ category: ResponseCodeCategory.ENGINEERING }), + }), + ); + expect(result).toEqual([mockCode]); + }); + }); + + describe('findByDocumentType', () => { + it('should return enabled codes for document type', async () => { + const result = await service.findByDocumentType(1, 1); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/frontend/app/(dashboard)/settings/delegation/page.tsx b/frontend/app/(dashboard)/settings/delegation/page.tsx new file mode 100644 index 00000000..6c07f306 --- /dev/null +++ b/frontend/app/(dashboard)/settings/delegation/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +// File: app/(dashboard)/settings/delegation/page.tsx +// หน้าจัดการ Delegation ของตัวเอง (FR-011) +import { useState } from 'react'; +import { Plus, ArrowRightLeft, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useMyDelegations, useCreateDelegation, useRevokeDelegation, Delegation } from '@/hooks/use-delegation'; +import { DelegationForm } from '@/components/delegation/DelegationForm'; + +export default function DelegationPage() { + const [createOpen, setCreateOpen] = useState(false); + + const { data: delegations = [], isLoading } = useMyDelegations(); + const createDelegation = useCreateDelegation(); + const revokeDelegation = useRevokeDelegation(); + + const today = new Date().toISOString().split('T')[0]; + + return ( +
+
+
+

Delegation Settings

+

+ มอบหมายหน้าที่ตรวจสอบให้ผู้อื่นในช่วงที่ไม่อยู่ +

+
+ + + + + + + + Create Delegation + + + createDelegation.mutate(dto, { + onSuccess: () => setCreateOpen(false), + }) + } + isLoading={createDelegation.isPending} + /> + + +
+ + {isLoading && ( +
Loading delegations...
+ )} + +
+ {(delegations as Delegation[]).map((d: Delegation) => { + const isActive = + d.isActive && d.startDate <= today && d.endDate >= today; + const isPast = d.endDate < today; + + return ( + + +
+
+ + + → {d.delegate?.fullName ?? d.delegate?.email ?? '—'} + + {isActive && Active} + {isPast && Expired} + {!isActive && !isPast && ( + Scheduled + )} + {d.scope} +
+ {!isPast && d.isActive && ( + + )} +
+
+ +

+ {d.startDate} → {d.endDate} + {d.reason && ` • ${d.reason}`} +

+
+
+ ); + })} + + {!isLoading && delegations.length === 0 && ( +
+ +

No delegations set. Create one when you need a proxy reviewer.

+
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/settings/review-teams/page.tsx b/frontend/app/(dashboard)/settings/review-teams/page.tsx new file mode 100644 index 00000000..c018f9b6 --- /dev/null +++ b/frontend/app/(dashboard)/settings/review-teams/page.tsx @@ -0,0 +1,167 @@ +'use client'; + +// File: app/(dashboard)/settings/review-teams/page.tsx +// หน้าจัดการ Review Teams (FR-001, FR-002) +import { useState } from 'react'; +import { Plus, Users, ChevronDown, ChevronUp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useReviewTeams, useCreateReviewTeam, useUpdateReviewTeam } from '@/hooks/use-review-teams'; +import { ReviewTeamForm } from '@/components/review-team/ReviewTeamForm'; +import { TeamMemberManager } from '@/components/review-team/TeamMemberManager'; +import { ReviewTeam } from '@/types/review-team'; + +// TODO: ดึง projectPublicId จาก context หรือ URL param จริง +const MOCK_PROJECT_ID = 'current-project-public-id'; + +export default function ReviewTeamsPage() { + const [expandedTeam, setExpandedTeam] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [editTeam, setEditTeam] = useState(null); + + const { data: teams = [], isLoading } = useReviewTeams({ + projectPublicId: MOCK_PROJECT_ID, + }); + + const createTeam = useCreateReviewTeam(); + const updateTeam = useUpdateReviewTeam(); + + return ( +
+
+
+

Review Teams

+

+ จัดการทีมตรวจสอบแยกตาม Discipline สำหรับ Parallel Review +

+
+ + + + + + + + Create Review Team + + + createTeam.mutate(values, { + onSuccess: () => setCreateOpen(false), + }) + } + isLoading={createTeam.isPending} + /> + + +
+ + {isLoading && ( +
Loading teams...
+ )} + +
+ {(teams as ReviewTeam[]).map((team) => ( + + +
+
+ + {team.name} + {!team.isActive && ( + Inactive + )} + {(team.defaultForRfaTypes ?? []).map((type) => ( + + {type} + + ))} +
+
+ + +
+
+ {team.description && ( +

{team.description}

+ )} +
+ + {expandedTeam === team.publicId && ( + +
+ Members ({(team.members ?? []).length}) +
+ +
+ )} +
+ ))} + + {!isLoading && (teams as ReviewTeam[]).length === 0 && ( +
+ +

No Review Teams yet. Create one to enable Parallel Review.

+
+ )} +
+ + {/* Edit Dialog */} + setEditTeam(null)}> + + + Edit Review Team + + {editTeam && ( + + updateTeam.mutate( + { publicId: editTeam.publicId, data: values }, + { onSuccess: () => setEditTeam(null) }, + ) + } + isLoading={updateTeam.isPending} + /> + )} + + +
+ ); +} diff --git a/frontend/components/delegation/DelegationForm.tsx b/frontend/components/delegation/DelegationForm.tsx new file mode 100644 index 00000000..fc7f07b1 --- /dev/null +++ b/frontend/components/delegation/DelegationForm.tsx @@ -0,0 +1,178 @@ +'use client'; + +// File: components/delegation/DelegationForm.tsx +// Form สร้าง Delegation พร้อม date range picker (FR-011) +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { CreateDelegationDto } from '@/hooks/use-delegation'; +import { DelegationScope } from '@/types/review-team'; + +const delegationSchema = z + .object({ + delegateUserPublicId: z.string().uuid('Select a valid user'), + scope: z.enum(['ALL', 'RFA_ONLY', 'CORRESPONDENCE_ONLY', 'SPECIFIC_TYPES'] as const), + startDate: z.string().min(1, 'Start date is required'), + endDate: z.string().min(1, 'End date is required'), + reason: z.string().max(500).optional(), + }) + .refine((d: { startDate: string; endDate: string }) => new Date(d.startDate) < new Date(d.endDate), { + message: 'End date must be after start date', + path: ['endDate'], + }); + +type DelegationFormValues = z.infer; + +interface User { + publicId: string; + fullName?: string; + email?: string; +} + +interface DelegationFormProps { + availableUsers: User[]; + onSubmit: (dto: CreateDelegationDto) => void; + isLoading?: boolean; +} + +const SCOPE_LABELS: Record = { + ALL: 'All Documents', + RFA_ONLY: 'RFA Only', + CORRESPONDENCE_ONLY: 'Correspondence Only', + SPECIFIC_TYPES: 'Specific Document Types', +}; + +export function DelegationForm({ availableUsers, onSubmit, isLoading }: DelegationFormProps) { + const form = useForm({ + resolver: zodResolver(delegationSchema), + defaultValues: { scope: 'ALL' }, + }); + + const handleSubmit = (values: DelegationFormValues) => { + onSubmit({ + ...values, + scope: values.scope as DelegationScope, + }); + }; + + return ( +
+ + ( + + Delegate To + + + + + + )} + /> + + ( + + Scope + + + + + + )} + /> + +
+ ( + + Start Date + + + + + + )} + /> + ( + + End Date + + + + + + )} + /> +
+ + ( + + Reason (Optional) + +