Compare commits

..

57 Commits

Author SHA1 Message Date
admin
83704377f4 251218:1701 On going update to 1.7.0: Documnet Number rebuild
Some checks are pending
Spec Validation / validate-markdown (push) Waiting to run
Spec Validation / validate-diagrams (push) Waiting to run
Spec Validation / check-todos (push) Waiting to run
2025-12-18 17:01:42 +07:00
admin
aaa5da3ec1 251217:1704 Docunment Number: Update to 1.6.2
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-17 17:04:06 +07:00
admin
48ed74a27b 251216:1715 Docunment Number: Update frontend/ and แก้ไข Gemini Code Assist
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-16 17:15:09 +07:00
admin
95ee94997f 251216:1644 Docunment Number: Update frontend/
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-16 16:44:23 +07:00
admin
9c1e175b76 251216:0946 Docunment Number: Update specs/ and backend/
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-16 09:46:37 +07:00
admin
78370fb590 251215:1719 Docunment Number Rule not correct
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-15 17:19:40 +07:00
admin
ec35521258 251213:1509 Docunment Number Businee Rule not correct
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-13 15:09:01 +07:00
admin
d964546c8d 251212:1650 Frontend: refactor Document Numbering)
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-12 16:50:52 +07:00
admin
2473c4c474 251211:1622 Frontend: refactor Dashboard (not finish)
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-11 16:22:50 +07:00
admin
3fa28bd14f 251211:1314 Frontend: reeactor Admin panel
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-11 13:14:15 +07:00
admin
c8a0f281ef 251210:1709 Frontend: reeactor organization and run build
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-10 17:09:11 +07:00
admin
aa96cd90e3 251209:1453 Frontend: progress nest = UAT & Bug Fixing
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-09 14:53:42 +07:00
8aceced902 251209:0000 Backend Test stagenot finish & Frontend add Task 013-015
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-09 00:00:28 +07:00
admin
863a727756 251208:1625 Frontend: to be complete admin panel, Backend: tobe recheck all task
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-08 16:25:56 +07:00
dcd126d704 251208:0010 Backend & Frontend Debug
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-08 00:10:37 +07:00
32d820ea6b 251207:0048 Update Schema & Data dictionary/ Login PASS
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-07 00:48:46 +07:00
admin
5c49bac772 251206:1710 specs: frontend plan P1,P3 wait Verification
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-06 17:10:56 +07:00
admin
be3b71007a 251206:1605 docs: update README and patch 2git.ps1
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-06 16:05:28 +07:00
admin
fea8ed6b80 Backup: docs: update README and patch 2git.ps1 | 2025-12-06 15:30:17
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
2025-12-06 15:30:17 +07:00
admin
c5250f3e70 Merge branch 'main' of github.com:peancharoen/lcbp3 2025-12-06 15:26:56 +07:00
admin
7e846bf045 Backup: docs: update README | 2025-12-06 15:21:14 2025-12-06 15:24:06 +07:00
admin
1123114f15 Backup: docs: update README | 2025-12-06 15:21:14 2025-12-06 15:21:14 +07:00
admin
381ffa44d7 remove large vsix file and add to gitignore 2025-12-06 14:47:36 +07:00
admin
0aaa139145 251206:1400 version 1.5.1 2025-12-06 14:42:32 +07:00
7dce419745 251205:2300 debug backend/frontend 2025-12-05 23:42:12 +07:00
9220884936 251205:1500 debug backend/frontend 2025-12-05 14:52:15 +07:00
2865bebdb1 251205:0000 Just start debug backend/frontend 2025-12-05 00:32:02 +07:00
admin
dc8b80c5f9 251204:1700 Prepare to version 1.5.1 2025-12-04 16:50:09 +07:00
05f8f4403a 251204:2300 Prepare 1.5.1 2025-12-04 00:01:18 +07:00
admin
3e6e810620 251203:1700 Prepare to version 1.6 2025-12-03 17:31:40 +07:00
1817158f25 251202:2300 Prepare 1.5.1 2025-12-03 01:16:27 +07:00
admin
9dcdba0bb3 251202:1700 Prepare to version 1.6 2025-12-02 17:29:42 +07:00
5acc631994 251202:1300 2025-12-02 13:26:05 +07:00
admin
d62beaa1bd 251202:1000 Prepare to version 1.5 use spec-kit 2025-12-02 10:01:47 +07:00
9fc7f692d9 251202:0000 2025-12-02 06:58:05 +07:00
admin
fc0580e14c 251201:1700 Prepare to version 1.5 use spec-kit 2025-12-01 17:00:32 +07:00
71c091055a Main: revise specs to 1.5.0 (completed) 2025-12-01 01:28:32 +07:00
admin
241022ada6 Prepare to version 1.5 use spec-kit 2025-11-30 13:58:46 +07:00
eff0169c21 251130:0000 1.4.5 Update Backend&Frontend Plan 2025-11-30 01:38:15 +07:00
admin
a78c9941be 251129:1700 update to 1.4.5 2025-11-29 16:50:34 +07:00
admin
f7a43600a3 251128:1700 Backend to T3.1.1 2025-11-28 17:12:05 +07:00
b22d00877e update workspace 2025-11-28 00:40:32 +07:00
305f66e23c update extension 2025-11-27 23:55:36 +07:00
admin
4f3aa87a93 251127:1700 Frontend Start Build 2025-11-27 17:08:49 +07:00
admin
6abb746e08 251126:1700 1.4.4 2025-11-26 16:43:48 +07:00
304f7fddf6 251126:1300 test run 2025-11-26 14:38:24 +07:00
admin
0a0c6645d5 251126:1200 แก้ไข document-numbering:ข้อกำหนด, ตาราง 2025-11-26 13:24:32 +07:00
647c35d8e4 251125:2300 build table 2025-11-26 00:43:17 +07:00
admin
5ede126cb8 251125:1200 6B 2025-11-25 21:15:02 +07:00
582ecb5741 251125:0000 Phase 6 wait start dev Check 2025-11-25 00:28:33 +07:00
admin
553c2d13ad 251124:1702 Ready to Phase 7 2025-11-24 17:03:36 +07:00
admin
4f45a69ed0 251124:1700 Ready to Phase 7 2025-11-24 17:01:58 +07:00
9360d78ea6 251123:2300 Update T1 2025-11-24 08:15:15 +07:00
23006898d9 251123:0200 T6.1 to DO 2025-11-23 02:23:38 +07:00
17d9f172d4 docs: Relocate project documentation files to a new subdirectory and add a versioned archive. 2025-11-22 20:30:51 +07:00
admin
a3474bff6a 251122:1700 Phase 4 2025-11-22 17:21:55 +07:00
admin
bf0308e350 251121:1700 Backend T3 wait testt 2025-11-21 17:16:40 +07:00
593 changed files with 54347 additions and 21858 deletions

View File

@@ -31,9 +31,9 @@ Before generating code or planning a solution, you MUST conceptually load the co
- *Crucial:* Check `specs/05-decisions/` (ADRs) to ensure you do not violate previously agreed-upon technical decisions. - *Crucial:* Check `specs/05-decisions/` (ADRs) to ensure you do not violate previously agreed-upon technical decisions.
5. **💾 DATABASE & SCHEMA (`specs/07-databasee/`)** 5. **💾 DATABASE & SCHEMA (`specs/07-databasee/`)**
- *Action:* - **Read `specs/07-database/lcbp3-v1.5.1-schema.sql`** (or relevant `.sql` files) for exact table structures and constraints. - *Action:* - **Read `specs/07-database/lcbp3-v1.6.0-schema.sql`** (or relevant `.sql` files) for exact table structures and constraints.
- **Consult `specs/database/data-dictionary-v1.5.1.md`** for field meanings and business rules. - **Consult `specs/07-database/data-dictionary-v1.6.0.md`** for field meanings and business rules.
- **Check `specs/database/lcbp3-v1.5.1-seed.sql`** to understand initial data states. - **Check `specs/07-database/lcbp3-v1.6.0-seed.sql`** to understand initial data states.
- *Constraint:* NEVER invent table names or columns. Use ONLY what is defined here. - *Constraint:* NEVER invent table names or columns. Use ONLY what is defined here.
6. **⚙️ IMPLEMENTATION DETAILS (`specs/03-implementation/`)** 6. **⚙️ IMPLEMENTATION DETAILS (`specs/03-implementation/`)**

10
.aignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
backend/node_modules/
frontend/node_modules/
backend/dist/
frontend/dist/
backend/build/
frontend/build/
docs/backup/
.git/
*.log

View File

@@ -1,3 +1,7 @@
---
trigger: always_on
---
# NAP-DMS Project Context & Rules # NAP-DMS Project Context & Rules
## 🧠 Role & Persona ## 🧠 Role & Persona
@@ -14,8 +18,8 @@ This is **LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)**.
## 💻 Tech Stack & Constraints ## 💻 Tech Stack & Constraints
- **Backend:** NestJS (Modular Architecture), TypeORM, MariaDB 10.11, Redis (BullMQ). - **Backend:** NestJS (Modular Architecture), TypeORM, MariaDB 10.11, Redis 7.2 (BullMQ), Elasticsearch 8.11, JWT (JSON Web Tokens), CASL (4-Level RBAC).
- **Frontend:** Next.js 14+ (App Router), Tailwind CSS, Shadcn/UI. - **Frontend:** Next.js 14+ (App Router), Tailwind CSS, Shadcn/UI, React Context / Zustand, React Hook Form + Zod, Axios.
- **Language:** TypeScript (Strict Mode). **NO `any` types allowed.** - **Language:** TypeScript (Strict Mode). **NO `any` types allowed.**
## 🛡️ Security & Integrity Rules ## 🛡️ Security & Integrity Rules
@@ -27,8 +31,8 @@ This is **LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)**.
## workflow Guidelines ## workflow Guidelines
- When implementing **Workflow Engine**, strictly follow the **DSL** design in `2_Backend_Plan_V1_4_4.Phase6A.md`. - When implementing strictly follow the documents in `specs/`.
- Always verify database schema against `4_Data_Dictionary_V1_4_4.md` before writing queries. - Always verify database schema against `specs/07-database/` before writing queries.
## 🚫 Forbidden Actions ## 🚫 Forbidden Actions

View File

@@ -1,4 +1,6 @@
node_modules node_modules
backend/node_modules/
frontend/node_modules/
dist dist
build build
*.min.js *.min.js

View File

@@ -33,6 +33,8 @@
"alefragnani.bookmarks", "alefragnani.bookmarks",
"pkief.material-icon-theme", "pkief.material-icon-theme",
"github.copilot", "github.copilot",
"bierner.markdown-mermaid" "bierner.markdown-mermaid",
"vitest.explorer",
"google.geminicodeassist"
] ]
} }

View File

@@ -0,0 +1 @@

View File

@@ -1,14 +1,38 @@
param([string]$Message = "Backup") param([string]$Message = "Backup")
$Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $Timestamp = Get-Date -Format "yyMMdd:HHmm"
$CommitMsg = "Backup: $Message | $Timestamp" $CommitMsg = "$Timestamp $Message"
Write-Host "Backup: $CommitMsg" -ForegroundColor Cyan Write-Host "📦 $CommitMsg" -ForegroundColor Cyan
git add . git add .
git commit -m $CommitMsg
git push origin main
git push github main
Write-Host "Done!" -ForegroundColor Green # Check if anything to commit
$status = git status --porcelain
if (-not $status) {
Write-Host "⚠️ Nothing to commit" -ForegroundColor Yellow
pause
exit
}
git commit -m $CommitMsg
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Commit failed" -ForegroundColor Red
pause
exit 1
}
Write-Host "🚀 Pushing to Gitea..." -ForegroundColor Cyan
git push origin main
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Push to Gitea failed" -ForegroundColor Red
}
Write-Host "🚀 Pushing to GitHub..." -ForegroundColor Cyan
git push github main
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Push to GitHub failed" -ForegroundColor Red
}
Write-Host "✅ Done!" -ForegroundColor Green
pause pause

View File

@@ -1,16 +1,191 @@
# Version history # Version History
## 1.4.5 (2025-11-28) ## [Unreleased]
### In Progress
- Backend Document Numbering Refactor (TASK-BE-017)
- E2E Testing & UAT preparation
- Production deployment preparation
## 1.7.0 (2025-12-18)
### Summary ### Summary
**Schema Stabilization & Document Numbering Overhaul** - Significant schema updates to support advanced document numbering (reservations, varying reset scopes) and a unified workflow engine.
- Backend development 80% remaining test tasks ### Database Schema Changes 💾
#### Document Numbering System (V2) 🔢
- **`document_number_counters`**:
- **Breaking Change**: Primary Key changed to 8-column Composite Key (`project_id`, `originator_id`, `recipient_id`, `type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `reset_scope`).
- **New Feature**: Added `reset_scope` column to support flexible resetting (YEAR, MONTH, PROJECT, NONE).
- **New Feature**: Added `version` column for Optimistic Locking.
- **`document_number_reservations`** (NEW):
- Implemented Two-Phase Commit pattern (Reserve -> Confirm) for document numbers.
- Prevents race conditions and gaps in numbering.
- **`document_number_errors`** (NEW):
- Helper table for tracking numbering failures and deadlocks.
- **`document_number_audit`**:
- Enhanced with reservation tokens and performance metrics.
#### Unified Workflow Engine 🔄
- **`workflow_definitions`**:
- Updated structure to support compiled DSL and versioning.
- Added `dsl` (JSON) and `compiled` (JSON) columns.
- **`workflow_instances`**:
- Changed ID to UUID.
- Added `entity_type` and `entity_id` for polymorphic polymorphism.
- **`workflow_histories`**:
- Updated to link with UUID instances.
#### System & Audit 🛡️
- **`audit_logs`**:
- Updated schema for better partitioning support (`created_at` in PK).
- Standardized JSON details column.
- **`notifications`**:
- Updated schema to support polymorphic entity linking.
#### Master Data
- **`disciplines`**:
- Added relation to `correspondences` and `rfas`.
### Documentation 📚
- **Data Dictionary**: Updated to v1.7.0 with full index summaries and business rules.
- **Schema**: Released `lcbp3-v1.7.0-schema.sql` and `lcbp3-v1.7.0-seed.sql`.
## 1.6.0 (2025-12-13)
### Summary
**Schema Refactoring Release** - Major restructuring of correspondence and RFA tables for improved data consistency.
### Database Schema Changes 💾
#### Breaking Changes ⚠️
- **`correspondence_recipients`**: FK changed from `correspondence_revisions(correspondence_id)``correspondences(id)`
- **`rfa_items`**: Column renamed `rfarev_correspondence_id``rfa_revision_id`
#### Schema Refactoring
- **`correspondences`**: Reordered columns, `discipline_id` now inline (no ALTER TABLE)
- **`correspondence_revisions`**:
- Renamed: `title``subject`
- Added: `body TEXT`, `remarks TEXT`, `schema_version INT`
- Added Virtual Columns: `v_ref_project_id`, `v_doc_subtype`
- **`rfas`**:
- Changed to Shared PK pattern (no AUTO_INCREMENT)
- PK now FK to `correspondences(id)`
- **`rfa_revisions`**:
- Removed: `correspondence_id` (uses rfas.id instead)
- Renamed: `title``subject`
- Added: `body TEXT`, `remarks TEXT`, `due_date DATETIME`, `schema_version INT`
- Added Virtual Column: `v_ref_drawing_count`
### Documentation 📚
- Updated Data Dictionary to v1.6.0
- Updated schema SQL files (`lcbp3-v1.6.0-schema.sql`, seed files)
## 1.5.1 (2025-12-10)
### Summary
**Major Milestone: System Feature Complete (~95%)** - Ready for UAT and production deployment.
All core modules implemented and operational. Backend and frontend fully integrated with comprehensive admin tools.
### Backend Completed ✅
#### Core Infrastructure
- ✅ All 18 core modules implemented and tested
- ✅ JWT Authentication with Refresh Token mechanism
- ✅ RBAC 4-Level (Global, Organization, Project, Contract) using CASL
- ✅ Document Numbering with Redis Redlock + Optimistic Locking
- ✅ Workflow Engine (DSL-based Hybrid Engine with legacy support)
- ✅ Two-Phase File Storage with ClamAV Virus Scanning
- ✅ Global Audit Logging with Interceptor
- ✅ Health Monitoring & Metrics endpoints
#### Business Modules
-**Correspondence Module** - Master-Revision pattern, Workflow integration, References
-**RFA Module** - Full CRUD, Item management, Revision handling, Approval workflow
-**Drawing Module** - Separated into Shop Drawing & Contract Drawing
-**Transmittal Module** - Document transmittal tracking
-**Circulation Module** - Circulation sheet management
-**Elasticsearch Integration** - Direct indexing, Full-text search (95% complete)
#### Supporting Services
-**Notification System** - Email and LINE notification integration
-**Master Data Management** - Consolidated service for Organizations, Projects, Disciplines, Types
-**User Management** - CRUD, Assignments, Preferences, Soft Delete
-**Dashboard Service** - Statistics and reporting APIs
-**JSON Schema Validation** - Dynamic schema validation for documents
### Frontend Completed ✅
#### Application Structure
- ✅ All 15 frontend tasks (FE-001 to FE-015) completed
- ✅ Next.js 14 App Router with TypeScript
- ✅ Complete UI implementation (17 component groups, 22 Shadcn/UI components)
- ✅ TanStack Query for server state management
- ✅ Zustand for client state management
- ✅ React Hook Form + Zod for form validation
- ✅ Responsive layout (Desktop & Mobile)
#### End-User Modules
-**Authentication UI** - Login, Token Management, Session Sync
-**RBAC UI** - `<Can />` component for permission-based rendering
-**Correspondence UI** - List, Create, Detail views with file uploads
-**RFA UI** - List, Create, Item management
-**Drawing UI** - Contract & Shop drawing lists, Upload forms
-**Search UI** - Global search bar, Advanced filtering with Elasticsearch
-**Dashboard** - Real-time KPI cards, Activity feed, Pending tasks
-**Circulation UI** - Circulation sheet management with DataTable
-**Transmittal UI** - Transmittal tracking and management
#### Admin Panel (10 Routes)
-**Workflow Configuration** - DSL Editor, Visual Builder, Workflow Definition management
-**Document Numbering Config** - Template Editor, Token Tester, Sequence Viewer
-**User Management** - CRUD, Role assignments, Preferences
-**Organization Management** - Organization CRUD and hierarchy
-**Project Management** - Project and contract administration
-**Reference Data Management** - CRUD for Disciplines, Types, Categories (6 modules)
-**Security Administration** - RBAC Matrix, Roles, Active Sessions (2 modules)
-**Audit Logs** - Comprehensive audit log viewer
-**System Logs** - System log monitoring
-**Settings** - System configuration
### Database 💾
- ✅ Schema v1.5.1 with standardized audit columns (`created_at`, `updated_at`, `deleted_at`)
- ✅ Complete seed data for all master tables
- ✅ Migration scripts and patches (`patch-audit-columns.sql`)
- ✅ Data Dictionary v1.5.1 documentation
### Documentation 📚
- ✅ Complete specs/ reorganization to v1.5.1
- ✅ 21 requirements documents in `specs/01-requirements/`
- ✅ 17 ADRs (Architecture Decision Records) in `specs/05-decisions/`
- ✅ Implementation guides for Backend & Frontend
- ✅ Operations guides for critical features (Document Numbering)
- ✅ Comprehensive progress reports updated
- ✅ Task archiving to `specs/09-history/` (27 completed tasks)
### Bug Fixes 🐛
- 🐛 Fixed role selection bug in User Edit form (2025-12-09)
- 🐛 Fixed workflow permissions - 403 error on workflow action endpoints
- 🐛 Fixed TypeORM relation errors in RFA and Drawing services
- 🐛 Fixed token refresh infinite loop in authentication
- 🐛 Fixed database schema alignment issues (audit columns)
- 🐛 Fixed "drawings.map is not a function" by handling paginated responses
- 🐛 Fixed invalid refresh token error loop
### Changed 📝
- 📝 Updated progress reports to reflect ~95% backend, 100% frontend completion
- 📝 Aligned all TypeORM entities with schema v1.5.1
- 📝 Enhanced data dictionary with business rules
- 📝 Archived 27 completed task files to `specs/09-history/`
## 1.5.0 (2025-11-30) ## 1.5.0 (2025-11-30)
### Summary
Initial spec-kit structure establishment and documentation organization.
### Changed ### Changed
- Changed the version to 1.5.0 - Changed the version to 1.5.0
- Modified to Spec-kit - Modified to Spec-kit
### Summary

View File

@@ -20,18 +20,19 @@
## 🗂️ Specification Structure ## 🗂️ Specification Structure
โครงสร้างเอกสาร Specifications ของโครงการแบ่งออกเป็น 6 หมวดหลัก: โครงสร้างเอกสาร Specifications ของโครงการแบ่งออกเป็น 9 หมวดหลัก:
``` ```
specs/ specs/
├── 00-overview/ # ภาพรวมโครงการ ├── 00-overview/ # ภาพรวมโครงการ (3 docs)
│ ├── README.md # Project overview │ ├── README.md # Project overview
── glossary.md # คำศัพท์เทคนิค ── glossary.md # คำศัพท์เทคนิค
│ └── quick-start.md # Quick start guide
├── 01-requirements/ # ข้อกำหนดระบบ ├── 01-requirements/ # ข้อกำหนดระบบ (21 docs)
│ ├── README.md # Requirements overview │ ├── README.md # Requirements overview
│ ├── 01-objectives.md # วัตถุประสงค์ │ ├── 01-objectives.md # วัตถุประสงค์
│ ├── 02-architecture.md # สถาปัตยกรรม │ ├── 02-architecture.md # สถาปัตยกรรม
│ ├── 03-functional-requirements.md │ ├── 03-functional-requirements.md
│ ├── 03.1-project-management.md │ ├── 03.1-project-management.md
│ ├── 03.2-correspondence.md │ ├── 03.2-correspondence.md
@@ -50,39 +51,59 @@ specs/
│ ├── 06-non-functional.md │ ├── 06-non-functional.md
│ └── 07-testing.md │ └── 07-testing.md
├── 02-architecture/ # สถาปัตยกรรมระบบ ├── 02-architecture/ # สถาปัตยกรรมระบบ (4 docs)
│ ├── README.md │ ├── README.md
│ ├── system-architecture.md │ ├── system-architecture.md
│ ├── api-design.md │ ├── api-design.md
│ └── data-model.md │ └── data-model.md
├── 03-implementation/ # แผนการพัฒนา ├── 03-implementation/ # แผนการพัฒนา (5 docs)
│ ├── README.md │ ├── README.md
│ ├── backend-plan.md │ ├── backend-guidelines.md
│ ├── frontend-plan.md │ ├── frontend-guidelines.md
── integration-plan.md ── testing-strategy.md
│ └── code-standards.md
├── 04-operations/ # การดำเนินงาน ├── 04-operations/ # การดำเนินงาน (9 docs)
│ ├── README.md │ ├── README.md
│ ├── deployment.md │ ├── deployment.md
── monitoring.md ── monitoring.md
│ └── ...
── 05-decisions/ # Architecture Decision Records ── 05-decisions/ # Architecture Decision Records (17 ADRs)
├── README.md ├── README.md
├── 001-workflow-engine.md ├── ADR-001-workflow-engine.md
── 002-file-storage.md ── ADR-002-document-numbering.md
│ └── ...
├── 06-tasks/ # Active Tasks & Progress (34 files)
│ ├── frontend-progress-report.md
│ ├── backend-progress-report.md
│ └── ...
├── 07-database/ # Database Schema (8 files)
│ ├── lcbp3-v1.7.0-schema.sql
│ ├── lcbp3-v1.7.0-seed.sql
│ ├── data-dictionary-v1.7.0.md
│ └── ...
└── 09-history/ # Archived Implementations (9 files)
└── ...
``` ```
### 📋 หมวดหมู่เอกสาร ### 📋 หมวดหมู่เอกสาร
| หมวด | วัตถุประสงค์ | ผู้ดูแล | | หมวด | วัตถุประสงค์ | ผู้ดูแล |
| --------------------- | ----------------------------- | ----------------------------- | | --------------------- | ----------------------------- | ----------------------------- |
| **00-overview** | ภาพรวมโครงการและคำศัพท์ | Project Manager | | **00-overview** | ภาพรวมโครงการและคำศัพท์ | Project Manager |
| **01-requirements** | ข้อกำหนดฟังก์ชันและระบบ | Business Analyst + Tech Lead | | **01-requirements** | ข้อกำหนดฟังก์ชันและระบบ | Business Analyst + Tech Lead |
| **02-architecture** | สถาปัตยกรรมและการออกแบบ | Tech Lead + Architects | | **02-architecture** | สถาปัตยกรรมและการออกแบบ | Tech Lead + Architects |
| **03-implementation** | แผนการพัฒนาและ Implementation | Development Team Leads | | **03-implementation** | แผนการพัฒนาและ Implementation | Development Team Leads |
| **04-operations** | Deployment และ Operations | DevOps Team | | **04-operations** | Deployment และ Operations | DevOps Team |
| **05-decisions** | Architecture Decision Records | Tech Lead + Senior Developers | | **05-decisions** | Architecture Decision Records | Tech Lead + Senior Developers |
| **06-tasks** | Active Tasks & Progress | All Team Members |
| **07-database** | Database Schema & Seed Data | Backend Lead + DBA |
| **09-history** | Archived Implementations | Tech Lead |
--- ---
@@ -454,11 +475,11 @@ Then [expected result]
### Review Levels ### Review Levels
| Level | Reviewer | Scope | | Level | Reviewer | Scope |
|-------|----------|-------| | ------------------------ | --------------- | ------------------------------- |
| **L1: Peer Review** | Team Member | Format, Clarity, Completeness | | **L1: Peer Review** | Team Member | Format, Clarity, Completeness |
| **L2: Technical Review** | Tech Lead | Technical Accuracy, Feasibility | | **L2: Technical Review** | Tech Lead | Technical Accuracy, Feasibility |
| **L3: Approval** | Project Manager | Business Alignment, Impact | | **L3: Approval** | Project Manager | Business Alignment, Impact |
### Review Timeline ### Review Timeline

196
README.md
View File

@@ -4,9 +4,23 @@
> >
> ระบบบริหารจัดการเอกสารโครงการแบบครบวงจร สำหรับโครงการก่อสร้างท่าเรือแหลมฉบังระยะที่ 3 > ระบบบริหารจัดการเอกสารโครงการแบบครบวงจร สำหรับโครงการก่อสร้างท่าเรือแหลมฉบังระยะที่ 3
[![Version](https://img.shields.io/badge/version-1.5.1-blue.svg)](./CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.7.0-blue.svg)](./CHANGELOG.md)
[![License](https://img.shields.io/badge/license-Internal-red.svg)]() [![License](https://img.shields.io/badge/license-Internal-red.svg)]()
[![Status](https://img.shields.io/badge/status-Active%20Development-green.svg)]() [![Status](https://img.shields.io/badge/status-Production%20Ready-brightgreen.svg)]()
---
## 📈 Current Status (As of 2025-12-18)
**Overall Progress: ~97% Feature Complete - Production Ready Preparation**
-**Backend**: Core modules implemented, refactoring for v1.7.0 Schema
-**Frontend**: UI tasks completed (100%), integrating new v1.7.0 features
-**Database**: Schema v1.7.0 active (Stabilized for Production)
-**Documentation**: Comprehensive specs/ at v1.7.0
-**Admin Tools**: Unified Workflow & Advanced Numbering Config
- 🔄 **Testing**: E2E tests and UAT preparation
- 📋 **Next**: Final Security Audit & Deployment
--- ---
@@ -41,7 +55,7 @@ LCBP3-DMS เป็นระบบบริหารจัดการเอก
```typescript ```typescript
{ {
"framework": "NestJS (TypeScript, ESM)", "framework": "NestJS (TypeScript, ESM)",
"database": "MariaDB 10.11", "database": "MariaDB 11.8",
"orm": "TypeORM", "orm": "TypeORM",
"authentication": "JWT + Passport", "authentication": "JWT + Passport",
"authorization": "CASL (RBAC)", "authorization": "CASL (RBAC)",
@@ -111,7 +125,7 @@ LCBP3-DMS เป็นระบบบริหารจัดการเอก
- **Node.js**: v20.x หรือสูงกว่า - **Node.js**: v20.x หรือสูงกว่า
- **pnpm**: v8.x หรือสูงกว่า - **pnpm**: v8.x หรือสูงกว่า
- **Docker**: v24.x หรือสูงกว่า - **Docker**: v24.x หรือสูงกว่า
- **MariaDB**: 10.11 - **MariaDB**: 11.8
- **Redis**: 7.x - **Redis**: 7.x
### การติดตั้ง ### การติดตั้ง
@@ -194,46 +208,88 @@ Superadmin:
``` ```
lcbp3-dms/ lcbp3-dms/
├── backend/ # NestJS Backend ├── backend/ # 🔧 NestJS Backend
│ ├── src/ │ ├── src/
│ │ ├── common/ # Shared modules │ │ ├── common/ # Shared utilities, guards, decorators
│ │ ├── modules/ # Feature modules │ │ ├── config/ # Configuration module
│ │ │ ├── auth/ │ │ ├── database/ # Database entities & migrations
│ │ │ ├── user/ │ │ ├── modules/ # Feature modules (17 modules)
│ │ │ ├── project/ │ │ │ ├── auth/ # JWT Authentication
│ │ │ ├── correspondence/ │ │ │ ├── user/ # User management & RBAC
│ │ │ ├── rfa/ │ │ │ ├── project/ # Project & Contract management
│ │ │ ├── drawing/ │ │ │ ├── correspondence/ # Correspondence module
│ │ │ ├── workflow-engine/ │ │ │ ├── rfa/ # Request for Approval
│ │ │ ── ... │ │ │ ── drawing/ # Contract & Shop Drawings
│ │ │ ├── workflow-engine/# DSL Workflow Engine
│ │ │ ├── document-numbering/ # Auto numbering
│ │ │ ├── transmittal/ # Transmittal management
│ │ │ ├── circulation/ # Circulation sheets
│ │ │ ├── search/ # Elasticsearch integration
│ │ │ ├── dashboard/ # Statistics & reporting
│ │ │ ├── notification/ # Email/LINE notifications
│ │ │ ├── monitoring/ # Health checks & metrics
│ │ │ ├── master/ # Master data management
│ │ │ ├── organizations/ # Organization management
│ │ │ └── json-schema/ # JSON Schema validation
│ │ └── main.ts │ │ └── main.ts
│ ├── test/ │ ├── test/ # Unit & E2E tests
│ └── package.json │ └── package.json
├── frontend/ # Next.js Frontend ├── frontend/ # 🎨 Next.js Frontend
│ ├── app/ # App Router │ ├── app/ # App Router
│ ├── components/ # React Components │ ├── (admin)/ # Admin panel routes
├── lib/ # Utilities └── admin/
│ │ │ ├── workflows/ # Workflow configuration
│ │ │ ├── numbering/ # Document numbering config
│ │ │ ├── users/ # User management
│ │ │ └── ...
│ │ ├── (auth)/ # Authentication pages
│ │ ├── (dashboard)/ # Main dashboard routes
│ │ │ ├── correspondences/
│ │ │ ├── rfas/
│ │ │ ├── drawings/
│ │ │ └── ...
│ │ └── api/ # API routes (NextAuth)
│ ├── components/ # React Components (15 groups)
│ │ ├── ui/ # Shadcn/UI components
│ │ ├── layout/ # Layout components
│ │ ├── common/ # Shared components
│ │ ├── correspondences/ # Correspondence UI
│ │ ├── rfas/ # RFA UI
│ │ ├── drawings/ # Drawing UI
│ │ ├── workflows/ # Workflow builder
│ │ ├── numbering/ # Numbering config UI
│ │ ├── dashboard/ # Dashboard widgets
│ │ ├── search/ # Search components
│ │ └── ...
│ ├── lib/ # Utilities & API clients
│ │ ├── api/ # API client functions
│ │ ├── services/ # Business logic services
│ │ └── stores/ # Zustand state stores
│ ├── types/ # TypeScript definitions
│ └── package.json │ └── package.json
├── docs/ # 📚 Legacy documentation ├── specs/ # 📘 Project Specifications (v1.5.1)
── ... ── 00-overview/ # Project overview & glossary
│ ├── 01-requirements/ # Functional requirements (21 docs)
│ ├── 02-architecture/ # System architecture
│ ├── 03-implementation/ # Implementation guidelines
│ ├── 04-operations/ # Deployment & operations
│ ├── 05-decisions/ # ADRs (17 decisions)
│ ├── 06-tasks/ # Active tasks & progress
│ ├── 07-database/ # Schema v1.5.1 & seed data
│ └── 09-history/ # Archived implementations
├── specs/ # 📘 Project Specifications (v1.5.1) ├── docs/ # 📚 Legacy documentation
│ ├── 00-overview/ # Project overview & glossary ├── diagrams/ # 📊 Architecture diagrams
│ ├── 01-requirements/ # Functional requirements ├── infrastructure/ # 🐳 Docker & Deployment configs
│ ├── 02-architecture/ # System architecture & ADRs
│ ├── 03-implementation/ # Implementation guidelines
│ ├── 04-operations/ # Deployment & operations
│ ├── 05-decisions/ # Architecture Decision Records
│ ├── 06-tasks/ # Active tasks
│ ├── 07-database/ # Database schema & seed data
│ └── 09-history/ # Implementation history
├── infrastructure/ # Docker & Deployment ├── .gemini/ # 🤖 AI agent configuration
│ └── Markdown/ # Legacy docs ├── .agent/ # Agent workflows
├── GEMINI.md # AI coding guidelines
── pnpm-workspace.yaml ── CONTRIBUTING.md # Contribution guidelines
├── CHANGELOG.md # Version history
└── pnpm-workspace.yaml # Monorepo configuration
``` ```
--- ---
@@ -248,16 +304,16 @@ lcbp3-dms/
| **Requirements** | ข้อกำหนดระบบและฟังก์ชันการทำงาน | `specs/01-requirements/` | | **Requirements** | ข้อกำหนดระบบและฟังก์ชันการทำงาน | `specs/01-requirements/` |
| **Architecture** | สถาปัตยกรรมระบบ, ADRs | `specs/02-architecture/` | | **Architecture** | สถาปัตยกรรมระบบ, ADRs | `specs/02-architecture/` |
| **Implementation** | แนวทางการพัฒนา Backend/Frontend | `specs/03-implementation/` | | **Implementation** | แนวทางการพัฒนา Backend/Frontend | `specs/03-implementation/` |
| **Database** | Schema v1.5.1 + Seed Data | `specs/07-database/` | | **Database** | Schema v1.7.0 + Seed Data | `specs/07-database/` |
### Schema & Seed Data ### Schema & Seed Data
```bash ```bash
# Import schema # Import schema
mysql -u root -p lcbp3_dev < specs/07-database/lcbp3-v1.5.1-schema.sql mysql -u root -p lcbp3_dev < specs/07-database/lcbp3-v1.7.0-schema.sql
# Import seed data # Import seed data
mysql -u root -p lcbp3_dev < specs/07-database/lcbp3-v1.5.1-seed.sql mysql -u root -p lcbp3_dev < specs/07-database/lcbp3-v1.7.0-seed.sql
``` ```
### Legacy Documentation ### Legacy Documentation
@@ -466,7 +522,7 @@ This project is **Internal Use Only** - ลิขสิทธิ์เป็น
สำหรับคำถามหรือปัญหา กรุณาติดต่อ: สำหรับคำถามหรือปัญหา กรุณาติดต่อ:
- **Email**: support@np-dms.work - **Email**: <support@np-dms.work>
- **Internal Chat**: [ระบุช่องทาง] - **Internal Chat**: [ระบุช่องทาง]
- **Issue Tracker**: [Gitea Issues](https://git.np-dms.work/lcbp3/lcbp3-dms/issues) - **Issue Tracker**: [Gitea Issues](https://git.np-dms.work/lcbp3/lcbp3-dms/issues)
@@ -474,26 +530,56 @@ This project is **Internal Use Only** - ลิขสิทธิ์เป็น
## 🗺️ Roadmap ## 🗺️ Roadmap
### Version 1.5.1 (Current - Dec 2025) ### Version 1.5.1 (Current - Dec 2025) ✅ **FEATURE COMPLETE**
- ✅ Core Infrastructure **Backend (18 Modules - ~95%)**
-Authentication & Authorization (JWT + CASL RBAC) -Core Infrastructure (Auth, RBAC, File Storage)
-**CASL RBAC 4-Level** - Global, Org, Project, Contract -Authentication & Authorization (JWT + CASL RBAC 4-Level)
-**Workflow DSL Parser** - Zod validation & state machine
- ✅ Correspondence Module (Master-Revision Pattern) - ✅ Correspondence Module (Master-Revision Pattern)
-**Document Number Audit** - Compliance tracking -RFA Module (Full CRUD + Workflow)
-**All Token Types** - Including {RECIPIENT} -Drawing Module (Contract + Shop Drawings)
- 🔄 RFA Module - ✅ Workflow Engine (DSL-based Hybrid)
- 🔄 Drawing Module - Document Numbering (Redlock + Optimistic Locking)
- ✅ Search (Elasticsearch Direct Indexing)
- ✅ Transmittal & Circulation Modules
- ✅ Notification & Audit Systems
- ✅ Master Data Management
- ✅ User Management
- ✅ Dashboard & Monitoring
- ✅ Swagger API Documentation - ✅ Swagger API Documentation
### Version 1.6.0 (Planned) **Frontend (15 Tasks - 100%)**
- ✅ Complete UI Implementation (17 component groups)
- ✅ All Business Modules (Correspondence, RFA, Drawings)
- ✅ Admin Panel (10 routes including Workflow & Numbering Config)
- ✅ Dashboard with Real-time Statistics
- ✅ Advanced Search UI
- ✅ RBAC Permission UI
- ✅ Responsive Layout (Desktop & Mobile)
- 📋 Advanced Reporting **Documentation**
- 📊 Dashboard Analytics - ✅ Complete specs/ v1.6.0 (21 requirements, 17 ADRs)
- 🔔 Enhanced Notifications (LINE/Email) - ✅ Database Schema v1.6.0 with seed data
- 🔄 E2E Tests for Critical APIs - ✅ Implementation & Operations Guides
- 📈 Prometheus Metrics
### Version 1.7.0 (Current - Dec 2025)
**Schema & Core Stabilization**
-**Document Numbering V2**: Composite Keys, Reservations, Reset Scopes
-**Unified Workflow Engine**: Compiled DSL, Polymorphic Instances
-**Data Dictionary**: Complete Update to v1.7.0
- 🔄 **Backend Refactor**: Aligning services with new schema
- 📋 **E2E Test Coverage**: Playwright/Cypress rollout
### Version 1.8.0+ (Planned - Q1 2026)
**Production Enhancements**
- 📊 Advanced Reporting & Analytics Dashboard
- 🔔 Enhanced Notifications (Real-time WebSocket)
- 📈 Prometheus Metrics & Grafana Dashboards
- 🔍 Queue-based Elasticsearch Indexing
- 🚀 Performance Optimization & Caching Strategy
- 📱 Mobile App (React Native)
--- ---

72
backend/build-output.txt Normal file
View File

@@ -0,0 +1,72 @@
> backend@1.5.1 build
> nest build
documentation/template-playground/hbs-render.service.ts:1:28 - error TS2307: Cannot find module '@angular/core' or its corresponding type declarations.
1 import { Injectable } from '@angular/core';
~~~~~~~~~~~~~~~
documentation/template-playground/hbs-render.service.ts:175:42 - error TS18046: 'error' is of type 'unknown'.
175 <p><strong>Error:</strong> ${error.message}</p>
~~~~~
documentation/template-playground/main.ts:1:40 - error TS2307: Cannot find module '@angular/platform-browser-dynamic' or its corresponding type declarations.
1 import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
documentation/template-playground/main.ts:8:12 - error TS7006: Parameter 'err' implicitly has an 'any' type.
8 .catch(err => console.error('Error starting template playground:', err));
~~~
documentation/template-playground/template-editor.service.ts:1:28 - error TS2307: Cannot find module '@angular/core' or its corresponding type declarations.
1 import { Injectable } from '@angular/core';
~~~~~~~~~~~~~~~
documentation/template-playground/template-playground.component.ts:1:69 - error TS2307: Cannot find module '@angular/core' or its corresponding type declarations.
1 import { Component, OnInit, ViewChild, ElementRef, OnDestroy } from '@angular/core';
~~~~~~~~~~~~~~~
documentation/template-playground/template-playground.component.ts:2:28 - error TS2307: Cannot find module '@angular/common/http' or its corresponding type declarations.
2 import { HttpClient } from '@angular/common/http';
~~~~~~~~~~~~~~~~~~~~~~
documentation/template-playground/template-playground.module.ts:1:26 - error TS2307: Cannot find module '@angular/core' or its corresponding type declarations.
1 import { NgModule } from '@angular/core';
~~~~~~~~~~~~~~~
documentation/template-playground/template-playground.module.ts:2:31 - error TS2307: Cannot find module '@angular/platform-browser' or its corresponding type declarations.
2 import { BrowserModule } from '@angular/platform-browser';
~~~~~~~~~~~~~~~~~~~~~~~~~~~
documentation/template-playground/template-playground.module.ts:3:30 - error TS2307: Cannot find module '@angular/common' or its corresponding type declarations.
3 import { CommonModule } from '@angular/common';
~~~~~~~~~~~~~~~~~
documentation/template-playground/template-playground.module.ts:4:29 - error TS2307: Cannot find module '@angular/forms' or its corresponding type declarations.
4 import { FormsModule } from '@angular/forms';
~~~~~~~~~~~~~~~~
documentation/template-playground/template-playground.module.ts:5:34 - error TS2307: Cannot find module '@angular/common/http' or its corresponding type declarations.
5 import { HttpClientModule } from '@angular/common/http';
~~~~~~~~~~~~~~~~~~~~~~
documentation/template-playground/zip-export.service.ts:1:28 - error TS2307: Cannot find module '@angular/core' or its corresponding type declarations.
1 import { Injectable } from '@angular/core';
~~~~~~~~~~~~~~~
src/modules/rfa/rfa.service.ts:422:11 - error TS2339: Property 'returnToSequence' does not exist on type 'WorkflowActionDto'.
422 dto.returnToSequence
~~~~~~~~~~~~~~~~
src/modules/rfa/rfa.service.ts:435:37 - error TS2551: Property 'comments' does not exist on type 'WorkflowActionDto'. Did you mean 'comment'?
435 currentRouting.comments = dto.comments;
~~~~~~~~
src/modules/correspondence/dto/workflow-action.dto.ts:29:3
29 comment?: string;
~~~~~~~
'comment' is declared here.
Found 15 error(s).

141
backend/build_log.txt Normal file
View File

@@ -0,0 +1,141 @@
> backend@1.5.1 build
> nest build
src/modules/document-numbering/controllers/document-numbering.controller.ts:93:37 - error TS2551: Property 'originatorOrganizationId' does not exist on type 'PreviewNumberDto'. Did you mean 'recipientOrganizationId'?
93 originatorOrganizationId: dto.originatorOrganizationId,
~~~~~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/dto/preview-number.dto.ts:27:3
27 recipientOrganizationId?: number;
~~~~~~~~~~~~~~~~~~~~~~~
'recipientOrganizationId' is declared here.
src/modules/document-numbering/controllers/document-numbering.controller.ts:94:19 - error TS2339: Property 'correspondenceTypeId' does not exist on type 'PreviewNumberDto'.
94 typeId: dto.correspondenceTypeId,
~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/controllers/document-numbering.controller.ts:100:25 - error TS2339: Property 'customTokens' does not exist on type 'PreviewNumberDto'.
100 customTokens: dto.customTokens,
~~~~~~~~~~~~
src/modules/document-numbering/dto/confirm-reservation.dto.ts:13:3 - error TS2564: Property 'documentNumber' has no initializer and is not definitely assigned in the constructor.
13 documentNumber: string;
~~~~~~~~~~~~~~
src/modules/document-numbering/dto/confirm-reservation.dto.ts:14:3 - error TS2564: Property 'confirmedAt' has no initializer and is not definitely assigned in the constructor.
14 confirmedAt: Date;
~~~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:2:3 - error TS2564: Property 'projectId' has no initializer and is not definitely assigned in the constructor.
2 projectId: number;
~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:3:3 - error TS2564: Property 'originatorOrganizationId' has no initializer and is not definitely assigned in the constructor.
3 originatorOrganizationId: number;
~~~~~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:4:3 - error TS2564: Property 'recipientOrganizationId' has no initializer and is not definitely assigned in the constructor.
4 recipientOrganizationId: number;
~~~~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:5:3 - error TS2564: Property 'correspondenceTypeId' has no initializer and is not definitely assigned in the constructor.
5 correspondenceTypeId: number;
~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:6:3 - error TS2564: Property 'subTypeId' has no initializer and is not definitely assigned in the constructor.
6 subTypeId: number;
~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:7:3 - error TS2564: Property 'rfaTypeId' has no initializer and is not definitely assigned in the constructor.
7 rfaTypeId: number;
~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:8:3 - error TS2564: Property 'disciplineId' has no initializer and is not definitely assigned in the constructor.
8 disciplineId: number;
~~~~~~~~~~~~
src/modules/document-numbering/dto/counter-key.dto.ts:9:3 - error TS2564: Property 'resetScope' has no initializer and is not definitely assigned in the constructor.
9 resetScope: string;
~~~~~~~~~~
src/modules/document-numbering/dto/reserve-number.dto.ts:5:3 - error TS2564: Property 'projectId' has no initializer and is not definitely assigned in the constructor.
5 projectId: number;
~~~~~~~~~
src/modules/document-numbering/dto/reserve-number.dto.ts:8:3 - error TS2564: Property 'originatorOrganizationId' has no initializer and is not definitely assigned in the constructor.
8 originatorOrganizationId: number;
~~~~~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/dto/reserve-number.dto.ts:15:3 - error TS2564: Property 'correspondenceTypeId' has no initializer and is not definitely assigned in the constructor.
15 correspondenceTypeId: number;
~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/dto/reserve-number.dto.ts:35:3 - error TS2564: Property 'token' has no initializer and is not definitely assigned in the constructor.
35 token: string;
~~~~~
src/modules/document-numbering/dto/reserve-number.dto.ts:36:3 - error TS2564: Property 'documentNumber' has no initializer and is not definitely assigned in the constructor.
36 documentNumber: string;
~~~~~~~~~~~~~~
src/modules/document-numbering/dto/reserve-number.dto.ts:37:3 - error TS2564: Property 'expiresAt' has no initializer and is not definitely assigned in the constructor.
37 expiresAt: Date;
~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:20:3 - error TS2564: Property 'id' has no initializer and is not definitely assigned in the constructor.
20 id: number;
~~
src/modules/document-numbering/entities/document-number-format.entity.ts:23:3 - error TS2564: Property 'projectId' has no initializer and is not definitely assigned in the constructor.
23 projectId: number;
~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:26:3 - error TS2564: Property 'correspondenceTypeId' has no initializer and is not definitely assigned in the constructor.
26 correspondenceTypeId: number | null;
~~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:29:3 - error TS2564: Property 'formatTemplate' has no initializer and is not definitely assigned in the constructor.
29 formatTemplate: string;
~~~~~~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:32:3 - error TS2564: Property 'description' has no initializer and is not definitely assigned in the constructor.
32 description: string;
~~~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:36:3 - error TS2564: Property 'resetSequenceYearly' has no initializer and is not definitely assigned in the constructor.
36 resetSequenceYearly: boolean;
~~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:39:3 - error TS2564: Property 'createdAt' has no initializer and is not definitely assigned in the constructor.
39 createdAt: Date;
~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:42:3 - error TS2564: Property 'updatedAt' has no initializer and is not definitely assigned in the constructor.
42 updatedAt: Date;
~~~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:47:3 - error TS2564: Property 'project' has no initializer and is not definitely assigned in the constructor.
47 project: Project;
~~~~~~~
src/modules/document-numbering/entities/document-number-format.entity.ts:51:3 - error TS2564: Property 'correspondenceType' has no initializer and is not definitely assigned in the constructor.
51 correspondenceType: CorrespondenceType | null;
~~~~~~~~~~~~~~~~~~
src/modules/document-numbering/services/document-numbering.service.ts:249:5 - error TS2740: Type 'DocumentNumberAudit[]' is missing the following properties from type 'DocumentNumberAudit': id, documentId, generatedNumber, counterKey, and 5 more.
249 return await this.auditRepo.save(audit);
~~~~~~
src/modules/document-numbering/services/document-numbering.service.ts:256:11 - error TS2769: No overload matches this call.
Overload 1 of 3, '(entityLikeArray: DeepPartial<DocumentNumberError>[]): DocumentNumberError[]', gave the following error.
Object literal may only specify known properties, and 'projectId' does not exist in type 'DeepPartial<DocumentNumberError>[]'.
Overload 2 of 3, '(entityLike: DeepPartial<DocumentNumberError>): DocumentNumberError', gave the following error.
Object literal may only specify known properties, and 'projectId' does not exist in type 'DeepPartial<DocumentNumberError>'.
256 projectId: ctx.projectId,
~~~~~~~~~
Found 31 error(s).

1416
backend/doc-output.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
services:
mariadb_test:
image: mariadb:11.8
container_name: mariadb-test
restart: always
environment:
MYSQL_ROOT_PASSWORD: Center#2025
MYSQL_DATABASE: lcbp3_test
MYSQL_USER: admin
MYSQL_PASSWORD: Center2025
ports:
- '3307:3306'
tmpfs:
- /var/lib/mysql
networks:
- lcbp3-test-net
redis_test:
image: redis:7-alpine
container_name: redis-test
restart: always
command: redis-server --requirepass "Center2025"
ports:
- '6380:6379'
networks:
- lcbp3-test-net
networks:
lcbp3-test-net:
driver: bridge

421
backend/e2e-output.txt Normal file
View File

@@ -0,0 +1,421 @@
> backend@1.5.1 test:e2e
> jest --config ./test/jest-e2e.json
[Nest] 13440 - 12/09/2025, 8:34:55 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17)
[Nest] 12240 - 12/09/2025, 8:34:55 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17)
[Nest] 41780 - 12/09/2025, 8:34:55 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17)
ΓùÅ Cannot log after tests are done. Did you forget to wait for something async in your test?
Attempted to log "AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}".
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}".
at console.error (../node_modules/@jest/console/build/index.js:124:10)
at Queue.emit (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue-base.ts:129:17)
at Queue.emit (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue.ts:192:18)
at RedisConnection.<anonymous> (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue-base.ts:75:56)
at EventEmitter.RedisConnection.handleClientError (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/redis-connection.ts:121:12)
at EventEmitter.silentEmit (../../node_modules/.pnpm/ioredis@5.8.2/node_modules/ioredis/built/Redis.js:484:30)
at Socket.<anonymous> (../../node_modules/.pnpm/ioredis@5.8.2/node_modules/ioredis/built/redis/event_handler.js:221:14)
FAIL test/app.e2e-spec.ts (7.608 s)
ΓùÅ Console
console.error
Redis Connection Error: AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}
72 | imports: [ConfigModule],
73 | useFactory: async (configService: ConfigService) => ({
> 74 | store: await redisStore({
| ^
75 | socket: {
76 | host: configService.get<string>('redis.host'),
77 | port: configService.get<number>('redis.port'),
at redisStore (../../node_modules/.pnpm/cache-manager-redis-yet@5.1.5/node_modules/cache-manager-redis-yet/dist/index.js:101:17)
at InstanceWrapper.useFactory [as metatype] (../src/app.module.ts:74:16)
at TestingInjector.instantiateClass (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:424:37)
at callback (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:70:34)
at TestingInjector.resolveConstructorParams (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:170:24)
at TestingInjector.loadInstance (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:75:13)
at TestingInjector.loadProvider (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:103:9)
at ../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:56:13
at async Promise.all (index 5)
at TestingInstanceLoader.createInstancesOfProviders (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:55:9)
at ../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:40:13
at async Promise.all (index 6)
at TestingInstanceLoader.createInstances (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:39:9)
at TestingInstanceLoader.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:22:13)
at TestingInstanceLoader.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-instance-loader.js:9:9)
at TestingModuleBuilder.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-module.builder.js:118:9)
at TestingModuleBuilder.compile (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-module.builder.js:74:9)
at Object.<anonymous> (app.e2e-spec.ts:11:42)
● AppController (e2e) › / (GET)
AggregateError:
ΓùÅ Cannot log after tests are done. Did you forget to wait for something async in your test?
Attempted to log "AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}".
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}".
at console.error (../node_modules/@jest/console/build/index.js:124:10)
at Queue.emit (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue-base.ts:129:17)
at Queue.emit (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue.ts:192:18)
at RedisConnection.<anonymous> (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue-base.ts:75:56)
at EventEmitter.RedisConnection.handleClientError (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/redis-connection.ts:121:12)
at EventEmitter.silentEmit (../../node_modules/.pnpm/ioredis@5.8.2/node_modules/ioredis/built/Redis.js:484:30)
at Socket.<anonymous> (../../node_modules/.pnpm/ioredis@5.8.2/node_modules/ioredis/built/redis/event_handler.js:221:14)
FAIL test/simple.e2e-spec.ts (7.616 s)
ΓùÅ Console
console.error
Redis Connection Error: AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}
72 | imports: [ConfigModule],
73 | useFactory: async (configService: ConfigService) => ({
> 74 | store: await redisStore({
| ^
75 | socket: {
76 | host: configService.get<string>('redis.host'),
77 | port: configService.get<number>('redis.port'),
at redisStore (../../node_modules/.pnpm/cache-manager-redis-yet@5.1.5/node_modules/cache-manager-redis-yet/dist/index.js:101:17)
at InstanceWrapper.useFactory [as metatype] (../src/app.module.ts:74:16)
at TestingInjector.instantiateClass (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:424:37)
at callback (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:70:34)
at TestingInjector.resolveConstructorParams (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:170:24)
at TestingInjector.loadInstance (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:75:13)
at TestingInjector.loadProvider (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:103:9)
at ../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:56:13
at async Promise.all (index 5)
at TestingInstanceLoader.createInstancesOfProviders (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:55:9)
at ../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:40:13
at async Promise.all (index 6)
at TestingInstanceLoader.createInstances (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:39:9)
at TestingInstanceLoader.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:22:13)
at TestingInstanceLoader.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-instance-loader.js:9:9)
at TestingModuleBuilder.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-module.builder.js:118:9)
at TestingModuleBuilder.compile (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-module.builder.js:74:9)
at Object.<anonymous> (simple.e2e-spec.ts:9:42)
● Simple Test › should pass
AggregateError:
ΓùÅ Cannot log after tests are done. Did you forget to wait for something async in your test?
Attempted to log "AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}".
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at afterConnectMultiple (../node:net:1708:16) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.callbackTrampoline (../node:internal/async_hooks:130:17) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}".
at console.error (../node_modules/@jest/console/build/index.js:124:10)
at Queue.emit (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue-base.ts:129:17)
at Queue.emit (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue.ts:192:18)
at RedisConnection.<anonymous> (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/queue-base.ts:75:56)
at EventEmitter.RedisConnection.handleClientError (../../node_modules/.pnpm/bullmq@5.65.0/node_modules/bullmq/src/classes/redis-connection.ts:121:12)
at EventEmitter.silentEmit (../../node_modules/.pnpm/ioredis@5.8.2/node_modules/ioredis/built/Redis.js:484:30)
at Socket.<anonymous> (../../node_modules/.pnpm/ioredis@5.8.2/node_modules/ioredis/built/redis/event_handler.js:221:14)
FAIL test/phase3-workflow.e2e-spec.ts (7.637 s)
ΓùÅ Console
console.error
Redis Connection Error: AggregateError:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 6379
},
Error: connect ECONNREFUSED 127.0.0.1:6379
at createConnectionError (node:net:1678:14)
at afterConnectMultiple (node:net:1708:16) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 6379
}
]
}
72 | imports: [ConfigModule],
73 | useFactory: async (configService: ConfigService) => ({
> 74 | store: await redisStore({
| ^
75 | socket: {
76 | host: configService.get<string>('redis.host'),
77 | port: configService.get<number>('redis.port'),
at redisStore (../../node_modules/.pnpm/cache-manager-redis-yet@5.1.5/node_modules/cache-manager-redis-yet/dist/index.js:101:17)
at InstanceWrapper.useFactory [as metatype] (../src/app.module.ts:74:16)
at TestingInjector.instantiateClass (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:424:37)
at callback (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:70:34)
at TestingInjector.resolveConstructorParams (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:170:24)
at TestingInjector.loadInstance (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:75:13)
at TestingInjector.loadProvider (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/injector.js:103:9)
at ../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:56:13
at async Promise.all (index 5)
at TestingInstanceLoader.createInstancesOfProviders (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:55:9)
at ../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:40:13
at async Promise.all (index 6)
at TestingInstanceLoader.createInstances (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:39:9)
at TestingInstanceLoader.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+core@11.1.9_@nestjs_89e063bd3a6d5071b082cab065bf34d7/node_modules/@nestjs/core/injector/instance-loader.js:22:13)
at TestingInstanceLoader.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-instance-loader.js:9:9)
at TestingModuleBuilder.createInstancesOfDependencies (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-module.builder.js:118:9)
at TestingModuleBuilder.compile (../../node_modules/.pnpm/@nestjs+testing@11.1.9_@nes_5fa0f54bf7d8c8acec998f5e81836857/node_modules/@nestjs/testing/testing-module.builder.js:74:9)
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:25:42)
● Phase 3 Workflow (E2E) › /correspondences (POST) - Create Document
AggregateError:
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit Workflow
AggregateError:
● Phase 3 Workflow (E2E) › /correspondences/:id/workflow/action (POST) - Approve Step
AggregateError:
ΓùÅ Test suite failed to run
TypeError: Cannot read properties of undefined (reading 'close')
70 | // Correspondence cleanup might be needed if not using a test DB
71 | }
> 72 | await app.close();
| ^
73 | });
74 |
75 | it('/correspondences (POST) - Create Document', async () => {
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:72:15)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 3 failed, 3 total
Tests: 5 failed, 5 total
Snapshots: 0 total
Time: 8.87 s
Ran all test suites.

109
backend/e2e-output10.txt Normal file
View File

@@ -0,0 +1,109 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 5332 - 12/09/2025, 11:25:20 AM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 5332 - 12/09/2025, 11:25:21 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0003-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
3,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0003-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 3, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0003-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 3, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
[Nest] 5332 - 12/09/2025, 11:25:21 AM ERROR [WorkflowEngineService] Transition Failed for c4765f7d-fb12-4ca8-9fa7-10a237069581: Cannot read properties of undefined (reading 'terminal')
[Nest] 5332 - 12/09/2025, 11:25:21 AM ERROR [CorrespondenceWorkflowService] Failed to submit workflow: TypeError: Cannot read properties of undefined (reading 'terminal')
[Nest] 5332 - 12/09/2025, 11:25:21 AM ERROR [ExceptionsHandler] TypeError: Cannot read properties of undefined (reading 'terminal')
at WorkflowEngineService.processTransition (D:\nap-dms.lcbp3\backend\src\modules\workflow-engine\workflow-engine.service.ts:274:36)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at CorrespondenceWorkflowService.submitWorkflow (D:\nap-dms.lcbp3\backend\src\modules\correspondence\correspondence-workflow.service.ts:73:32)
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.log
Created Correspondence ID: 5
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.warn
Skipping action test - no instanceId from submit
104 | // Skip if submit failed to get instanceId
105 | if (!workflowInstanceId) {
> 106 | console.warn('Skipping action test - no instanceId from submit');
| ^
107 | return;
108 | }
109 |
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:106:15)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
expected 201 "Created", got 500 "Internal Server Error"
92 | note: 'Submitting for E2E test',
93 | })
> 94 | .expect(201);
| ^
95 |
96 | expect(response.body).toHaveProperty('instanceId');
97 | expect(response.body).toHaveProperty('currentState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:94:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 4 passed, 5 total
Snapshots: 0 total
Time: 5.321 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

100
backend/e2e-output11.txt Normal file
View File

@@ -0,0 +1,100 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 16184 - 12/09/2025, 11:27:54 AM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 16184 - 12/09/2025, 11:27:54 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0004-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
4,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0004-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 4, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0004-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 4, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.log
Created Correspondence ID: 6
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.log
Workflow Instance ID: 3577a2e1-bada-4fe7-84f1-876ec83b0624
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:99:13)
console.log
Current State: IN_REVIEW
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:100:13)
● Phase 3 Workflow (E2E) › /correspondences/:id/workflow/action (POST) - Process Action
expected 201 "Created", got 403 "Forbidden"
116 | comment: 'E2E Approved via Unified Workflow Engine',
117 | })
> 118 | .expect(201);
| ^
119 |
120 | expect(response.body).toHaveProperty('success', true);
121 | expect(response.body).toHaveProperty('nextState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:118:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 4 passed, 5 total
Snapshots: 0 total
Time: 5.67 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

100
backend/e2e-output12.txt Normal file
View File

@@ -0,0 +1,100 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 7212 - 12/09/2025, 11:32:17 AM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 7212 - 12/09/2025, 11:32:17 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0005-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
5,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0005-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 5, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0005-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 5, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.log
Created Correspondence ID: 7
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.log
Workflow Instance ID: 20c439a2-841c-40a1-96e7-5c9f8dfe234f
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:99:13)
console.log
Current State: IN_REVIEW
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:100:13)
● Phase 3 Workflow (E2E) › /correspondences/:id/workflow/action (POST) - Process Action
expected 201 "Created", got 403 "Forbidden"
116 | comment: 'E2E Approved via Unified Workflow Engine',
117 | })
> 118 | .expect(201);
| ^
119 |
120 | expect(response.body).toHaveProperty('success', true);
121 | expect(response.body).toHaveProperty('nextState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:118:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 4 passed, 5 total
Snapshots: 0 total
Time: 5.533 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

100
backend/e2e-output13.txt Normal file
View File

@@ -0,0 +1,100 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 46180 - 12/09/2025, 11:40:20 AM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 46180 - 12/09/2025, 11:40:20 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0006-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
6,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0006-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 6, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0006-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 6, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.log
Created Correspondence ID: 8
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.log
Workflow Instance ID: 9fc9ddd7-5257-4363-b1f1-f9c22f581b44
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:99:13)
console.log
Current State: IN_REVIEW
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:100:13)
● Phase 3 Workflow (E2E) › /correspondences/:id/workflow/action (POST) - Process Action
expected 201 "Created", got 403 "Forbidden"
116 | comment: 'E2E Approved via Unified Workflow Engine',
117 | })
> 118 | .expect(201);
| ^
119 |
120 | expect(response.body).toHaveProperty('success', true);
121 | expect(response.body).toHaveProperty('nextState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:118:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 4 passed, 5 total
Snapshots: 0 total
Time: 5.568 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

84
backend/e2e-output14.txt Normal file
View File

@@ -0,0 +1,84 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 38304 - 12/09/2025, 12:13:26 PM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 38304 - 12/09/2025, 12:13:26 PM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0007-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
7,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0007-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 7, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0007-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 7, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
PASS test/phase3-workflow.e2e-spec.ts (5.236 s)
ΓùÅ Console
console.log
Created Correspondence ID: 9
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.log
Workflow Instance ID: d601ef06-93e0-435c-ad76-fc6e3dee5c22
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:99:13)
console.log
Current State: IN_REVIEW
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:100:13)
console.log
Action Result: { success: true, nextState: 'APPROVED', events: [], isCompleted: true }
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:122:13)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 3 passed, 3 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 6.691 s
Ran all test suites.

84
backend/e2e-output15.txt Normal file
View File

@@ -0,0 +1,84 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 38760 - 12/09/2025, 12:16:40 PM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 38760 - 12/09/2025, 12:16:40 PM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0008-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
8,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0008-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 8, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0008-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 8, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
PASS test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.log
Created Correspondence ID: 10
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.log
Workflow Instance ID: 5057da48-f0e5-4d1a-86f1-a1b96929a6eb
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:99:13)
console.log
Current State: IN_REVIEW
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:100:13)
console.log
Action Result: { success: true, nextState: 'APPROVED', events: [], isCompleted: true }
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:122:13)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 3 passed, 3 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 5.885 s, estimated 6 s
Ran all test suites.

63
backend/e2e-output2.txt Normal file
View File

@@ -0,0 +1,63 @@
> backend@1.5.1 test:e2e
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts (7.275 s)
PASS test/app.e2e-spec.ts (7.566 s)
FAIL test/phase3-workflow.e2e-spec.ts (7.639 s)
● Phase 3 Workflow (E2E) › /correspondences (POST) - Create Document
QueryFailedError: Table 'lcbp3_dev.correspondence_routing_templates' doesn't exist
at Query.onResult (../../node_modules/.pnpm/typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08/src/driver/mysql/MysqlQueryRunner.ts:248:33)
at Query.execute (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/commands/command.js:36:14)
at PoolConnection.handlePacket (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:477:34)
at PacketParser.onPacket (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:93:12)
at PacketParser.executeStart (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/packet_parser.js:75:16)
at Socket.<anonymous> (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:100:25)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit Workflow
QueryFailedError: Table 'lcbp3_dev.correspondence_routing_templates' doesn't exist
at Query.onResult (../../node_modules/.pnpm/typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08/src/driver/mysql/MysqlQueryRunner.ts:248:33)
at Query.execute (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/commands/command.js:36:14)
at PoolConnection.handlePacket (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:477:34)
at PacketParser.onPacket (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:93:12)
at PacketParser.executeStart (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/packet_parser.js:75:16)
at Socket.<anonymous> (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:100:25)
● Phase 3 Workflow (E2E) › /correspondences/:id/workflow/action (POST) - Approve Step
QueryFailedError: Table 'lcbp3_dev.correspondence_routing_templates' doesn't exist
at Query.onResult (../../node_modules/.pnpm/typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08/src/driver/mysql/MysqlQueryRunner.ts:248:33)
at Query.execute (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/commands/command.js:36:14)
at PoolConnection.handlePacket (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:477:34)
at PacketParser.onPacket (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:93:12)
at PacketParser.executeStart (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/packet_parser.js:75:16)
at Socket.<anonymous> (../../node_modules/.pnpm/mysql2@3.15.3/node_modules/mysql2/lib/base/connection.js:100:25)
ΓùÅ Test suite failed to run
TypeORMError: Empty criteria(s) are not allowed for the delete method.
67 | if (dataSource) {
68 | const templateRepo = dataSource.getRepository(RoutingTemplate);
> 69 | await templateRepo.delete(templateId);
| ^
70 | // Correspondence cleanup might be needed if not using a test DB
71 | }
72 | await app.close();
at EntityManager.delete (../../node_modules/.pnpm/typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08/src/entity-manager/EntityManager.ts:849:17)
at Repository.delete (../../node_modules/.pnpm/typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08/src/repository/Repository.ts:420:35)
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:69:32)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 3 failed, 2 passed, 5 total
Snapshots: 0 total
Time: 9.08 s
Ran all test suites.

165
backend/e2e-output3.txt Normal file
View File

@@ -0,0 +1,165 @@
> backend@1.5.1 test:e2e
> jest --config ./test/jest-e2e.json
[Nest] 28712 - 12/09/2025, 9:48:43 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
TypeORMError: Entity metadata for RoutingTemplate#steps was not found. Check if you specified a correct entity object and if it's connected in the connection options.
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1128:23
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.computeInverseProperties (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1118:34)
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:160:18
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.build (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:159:25)
at ConnectionMetadataBuilder.buildEntityMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\connection\ConnectionMetadataBuilder.ts:106:11)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at DataSource.buildMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:733:13)
at DataSource.initialize (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:264:13)
[Nest] 40512 - 12/09/2025, 9:48:43 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
TypeORMError: Entity metadata for RoutingTemplate#steps was not found. Check if you specified a correct entity object and if it's connected in the connection options.
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1128:23
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.computeInverseProperties (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1118:34)
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:160:18
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.build (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:159:25)
at ConnectionMetadataBuilder.buildEntityMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\connection\ConnectionMetadataBuilder.ts:106:11)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at DataSource.buildMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:733:13)
at DataSource.initialize (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:264:13)
[Nest] 41884 - 12/09/2025, 9:48:43 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
TypeORMError: Entity metadata for RoutingTemplate#steps was not found. Check if you specified a correct entity object and if it's connected in the connection options.
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1128:23
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.computeInverseProperties (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1118:34)
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:160:18
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.build (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:159:25)
at ConnectionMetadataBuilder.buildEntityMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\connection\ConnectionMetadataBuilder.ts:106:11)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at DataSource.buildMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:733:13)
at DataSource.initialize (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:264:13)
[Nest] 41884 - 12/09/2025, 9:48:46 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (2)...
TypeORMError: Entity metadata for RoutingTemplate#steps was not found. Check if you specified a correct entity object and if it's connected in the connection options.
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1128:23
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.computeInverseProperties (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1118:34)
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:160:18
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.build (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:159:25)
at ConnectionMetadataBuilder.buildEntityMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\connection\ConnectionMetadataBuilder.ts:106:11)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at DataSource.buildMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:733:13)
at DataSource.initialize (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:264:13)
[Nest] 28712 - 12/09/2025, 9:48:46 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (2)...
TypeORMError: Entity metadata for RoutingTemplate#steps was not found. Check if you specified a correct entity object and if it's connected in the connection options.
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1128:23
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.computeInverseProperties (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1118:34)
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:160:18
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.build (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:159:25)
at ConnectionMetadataBuilder.buildEntityMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\connection\ConnectionMetadataBuilder.ts:106:11)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at DataSource.buildMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:733:13)
at DataSource.initialize (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:264:13)
[Nest] 40512 - 12/09/2025, 9:48:46 AM ERROR [TypeOrmModule] Unable to connect to the database. Retrying (2)...
TypeORMError: Entity metadata for RoutingTemplate#steps was not found. Check if you specified a correct entity object and if it's connected in the connection options.
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1128:23
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.computeInverseProperties (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:1118:34)
at D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:160:18
at Array.forEach (<anonymous>)
at EntityMetadataBuilder.build (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\metadata-builder\EntityMetadataBuilder.ts:159:25)
at ConnectionMetadataBuilder.buildEntityMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\connection\ConnectionMetadataBuilder.ts:106:11)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at DataSource.buildMetadatas (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:733:13)
at DataSource.initialize (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\data-source\DataSource.ts:264:13)
FAIL test/app.e2e-spec.ts (8.781 s)
● AppController (e2e) › / (GET)
thrown: "Exceeded timeout of 5000 ms for a hook.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."
8 | let app: INestApplication<App>;
9 |
> 10 | beforeEach(async () => {
| ^
11 | const moduleFixture: TestingModule = await Test.createTestingModule({
12 | imports: [AppModule],
13 | }).compile();
at app.e2e-spec.ts:10:3
at Object.<anonymous> (app.e2e-spec.ts:7:1)
FAIL test/phase3-workflow.e2e-spec.ts (8.787 s)
● Phase 3 Workflow (E2E) › /correspondences (POST) - Create Document
thrown: "Exceeded timeout of 5000 ms for a hook.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."
27 | let adminToken: string;
28 |
> 29 | beforeAll(async () => {
| ^
30 | const moduleFixture: TestingModule = await Test.createTestingModule({
31 | imports: [AppModule],
32 | }).compile();
at phase3-workflow.e2e-spec.ts:29:3
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:15:1)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
thrown: "Exceeded timeout of 5000 ms for a hook.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."
27 | let adminToken: string;
28 |
> 29 | beforeAll(async () => {
| ^
30 | const moduleFixture: TestingModule = await Test.createTestingModule({
31 | imports: [AppModule],
32 | }).compile();
at phase3-workflow.e2e-spec.ts:29:3
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:15:1)
● Phase 3 Workflow (E2E) › /correspondences/:id/workflow/action (POST) - Process Action
thrown: "Exceeded timeout of 5000 ms for a hook.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."
27 | let adminToken: string;
28 |
> 29 | beforeAll(async () => {
| ^
30 | const moduleFixture: TestingModule = await Test.createTestingModule({
31 | imports: [AppModule],
32 | }).compile();
at phase3-workflow.e2e-spec.ts:29:3
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:15:1)
FAIL test/simple.e2e-spec.ts (8.797 s)
● Simple Test › should pass
thrown: "Exceeded timeout of 5000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."
6 |
7 | describe('Simple Test', () => {
> 8 | it('should pass', async () => {
| ^
9 | const moduleFixture: TestingModule = await Test.createTestingModule({
10 | imports: [AppModule],
11 | }).compile();
at simple.e2e-spec.ts:8:3
at Object.<anonymous> (simple.e2e-spec.ts:7:1)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 3 failed, 3 total
Tests: 5 failed, 5 total
Snapshots: 0 total
Time: 9.98 s
Ran all test suites.

83
backend/e2e-output4.txt Normal file
View File

@@ -0,0 +1,83 @@
> backend@1.5.1 test:e2e
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.warn
WorkflowDefinition CORRESPONDENCE_FLOW_V1 not found. Tests may fail.
55 |
56 | if (!existing) {
> 57 | console.warn(
| ^
58 | 'WorkflowDefinition CORRESPONDENCE_FLOW_V1 not found. Tests may fail.'
59 | );
60 | }
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:57:15)
console.warn
Skipping action test - no instanceId from submit
104 | // Skip if submit failed to get instanceId
105 | if (!workflowInstanceId) {
> 106 | console.warn('Skipping action test - no instanceId from submit');
| ^
107 | return;
108 | }
109 |
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:106:15)
● Phase 3 Workflow (E2E) › /correspondences (POST) - Create Document
expected 201 "Created", got 403 "Forbidden"
77 | details: { question: 'Testing Unified Workflow' },
78 | })
> 79 | .expect(201);
| ^
80 |
81 | expect(response.body).toHaveProperty('id');
82 | expect(response.body).toHaveProperty('correspondenceNumber');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:79:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
expected 201 "Created", got 403 "Forbidden"
92 | note: 'Submitting for E2E test',
93 | })
> 94 | .expect(201);
| ^
95 |
96 | expect(response.body).toHaveProperty('instanceId');
97 | expect(response.body).toHaveProperty('currentState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:94:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 2 failed, 3 passed, 5 total
Snapshots: 0 total
Time: 5.219 s, estimated 9 s
Ran all test suites.

214
backend/e2e-output5.txt Normal file
View File

@@ -0,0 +1,214 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 45012 - 12/09/2025, 10:04:29 AM ERROR [DocumentNumberingService] Error generating number for doc_num:1:1:0:2025
[Nest] 45012 - 12/09/2025, 10:04:29 AM ERROR [DocumentNumberingService] QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `correspondence_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (?, ?, ?, ?, ?, ?, 1) RETURNING `discipline_id`, `last_number`, `version`',
parameters: [
1,
41,
1,
0,
2025,
1
],
driverError: Error: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `correspondence_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, 1, 0, 2025, 1, 1) RETURNING `discipline_id`, `last_number`, `version`'
},
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `correspondence_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, 1, 0, 2025, 1, 1) RETURNING `discipline_id`, `last_number`, `version`'
}
[Nest] 45012 - 12/09/2025, 10:04:29 AM ERROR [DocumentNumberingService] Failed to log error
[Nest] 45012 - 12/09/2025, 10:04:29 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'error_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, DEFAULT) RETURNING `id`, `error_at`',
parameters: [
'doc_num:1:1:0:2025',
'DB_ERROR',
'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\n at Query.onResult (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\src\\driver\\mysql\\MysqlQueryRunner.ts:248:33)\n at Query.execute (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\commands\\command.js:36:14)\n at PoolConnection.handlePacket (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:477:34)\n at PacketParser.onPacket (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:93:12)\n at PacketParser.executeStart (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\packet_parser.js:75:16)\n at Socket.<anonymous> (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:100:25)\n at Socket.emit (node:events:519:28)\n at addChunk (node:internal/streams/readable:561:12)\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\n at Socket.Readable.push (node:internal/streams/readable:392:5)\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)',
'{"projectId":1,"originatorId":41,"typeId":1,"year":2025,"customTokens":{"TYPE_CODE":"RFA","ORG_CODE":"ORG"}}'
],
driverError: Error: Unknown column 'error_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'error_at' in 'RETURNING'",
sql: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, \'doc_num:1:1:0:2025\', \'DB_ERROR\', \'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\', \'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\\n at Query.onResult (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\\\src\\\\driver\\\\mysql\\\\MysqlQueryRunner.ts:248:33)\\n at Query.execute (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\commands\\\\command.js:36:14)\\n at PoolConnection.handlePacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:477:34)\\n at PacketParser.onPacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:93:12)\\n at PacketParser.executeStart (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\packet_parser.js:75:16)\\n at Socket.<anonymous> (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:100:25)\\n at Socket.emit (node:events:519:28)\\n at addChunk (node:internal/streams/readable:561:12)\\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\\n at Socket.Readable.push (node:internal/streams/readable:392:5)\\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)\', DEFAULT, DEFAULT, \'{\\"projectId\\":1,\\"originatorId\\":41,\\"typeId\\":1,\\"year\\":2025,\\"customTokens\\":{\\"TYPE_CODE\\":\\"RFA\\",\\"ORG_CODE\\":\\"ORG\\"}}\', DEFAULT) RETURNING `id`, `error_at`'
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'error_at' in 'RETURNING'",
sql: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, \'doc_num:1:1:0:2025\', \'DB_ERROR\', \'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\', \'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\\n at Query.onResult (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\\\src\\\\driver\\\\mysql\\\\MysqlQueryRunner.ts:248:33)\\n at Query.execute (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\commands\\\\command.js:36:14)\\n at PoolConnection.handlePacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:477:34)\\n at PacketParser.onPacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:93:12)\\n at PacketParser.executeStart (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\packet_parser.js:75:16)\\n at Socket.<anonymous> (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:100:25)\\n at Socket.emit (node:events:519:28)\\n at addChunk (node:internal/streams/readable:561:12)\\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\\n at Socket.Readable.push (node:internal/streams/readable:392:5)\\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)\', DEFAULT, DEFAULT, \'{\\"projectId\\":1,\\"originatorId\\":41,\\"typeId\\":1,\\"year\\":2025,\\"customTokens\\":{\\"TYPE_CODE\\":\\"RFA\\",\\"ORG_CODE\\":\\"ORG\\"}}\', DEFAULT) RETURNING `id`, `error_at`'
}
[Nest] 45012 - 12/09/2025, 10:04:29 AM ERROR [CorrespondenceService] Failed to create correspondence: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
[Nest] 45012 - 12/09/2025, 10:04:29 AM ERROR [ExceptionsHandler] QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `correspondence_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (?, ?, ?, ?, ?, ?, 1) RETURNING `discipline_id`, `last_number`, `version`',
parameters: [
1,
41,
1,
0,
2025,
1
],
driverError: Error: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `correspondence_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, 1, 0, 2025, 1, 1) RETURNING `discipline_id`, `last_number`, `version`'
},
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `correspondence_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, 1, 0, 2025, 1, 1) RETURNING `discipline_id`, `last_number`, `version`'
}
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.warn
Skipping action test - no instanceId from submit
104 | // Skip if submit failed to get instanceId
105 | if (!workflowInstanceId) {
> 106 | console.warn('Skipping action test - no instanceId from submit');
| ^
107 | return;
108 | }
109 |
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:106:15)
● Phase 3 Workflow (E2E) › /correspondences (POST) - Create Document
expected 201 "Created", got 500 "Internal Server Error"
77 | details: { question: 'Testing Unified Workflow' },
78 | })
> 79 | .expect(201);
| ^
80 |
81 | expect(response.body).toHaveProperty('id');
82 | expect(response.body).toHaveProperty('correspondenceNumber');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:79:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
expected 201 "Created", got 400 "Bad Request"
92 | note: 'Submitting for E2E test',
93 | })
> 94 | .expect(201);
| ^
95 |
96 | expect(response.body).toHaveProperty('instanceId');
97 | expect(response.body).toHaveProperty('currentState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:94:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 2 failed, 3 passed, 5 total
Snapshots: 0 total
Time: 5.122 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

220
backend/e2e-output6.txt Normal file
View File

@@ -0,0 +1,220 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts (7.012 s)
PASS test/app.e2e-spec.ts (7.175 s)
[Nest] 22264 - 12/09/2025, 10:27:45 AM ERROR [DocumentNumberingService] Error generating number for doc_num:1:1:0:2025
[Nest] 22264 - 12/09/2025, 10:27:45 AM ERROR [DocumentNumberingService] QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`',
parameters: [
1,
41,
-1,
1,
0,
0,
0,
2025,
1
],
driverError: Error: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
},
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
}
[Nest] 22264 - 12/09/2025, 10:27:45 AM ERROR [DocumentNumberingService] Failed to log error
[Nest] 22264 - 12/09/2025, 10:27:45 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'error_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, DEFAULT) RETURNING `id`, `error_at`',
parameters: [
'doc_num:1:1:0:2025',
'DB_ERROR',
'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\n at Query.onResult (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\src\\driver\\mysql\\MysqlQueryRunner.ts:248:33)\n at Query.execute (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\commands\\command.js:36:14)\n at PoolConnection.handlePacket (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:477:34)\n at PacketParser.onPacket (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:93:12)\n at PacketParser.executeStart (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\packet_parser.js:75:16)\n at Socket.<anonymous> (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:100:25)\n at Socket.emit (node:events:519:28)\n at addChunk (node:internal/streams/readable:561:12)\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\n at Socket.Readable.push (node:internal/streams/readable:392:5)\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)',
'{"projectId":1,"originatorId":41,"typeId":1,"year":2025,"customTokens":{"TYPE_CODE":"RFA","ORG_CODE":"ORG"}}'
],
driverError: Error: Unknown column 'error_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'error_at' in 'RETURNING'",
sql: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, \'doc_num:1:1:0:2025\', \'DB_ERROR\', \'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\', \'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\\n at Query.onResult (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\\\src\\\\driver\\\\mysql\\\\MysqlQueryRunner.ts:248:33)\\n at Query.execute (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\commands\\\\command.js:36:14)\\n at PoolConnection.handlePacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:477:34)\\n at PacketParser.onPacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:93:12)\\n at PacketParser.executeStart (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\packet_parser.js:75:16)\\n at Socket.<anonymous> (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:100:25)\\n at Socket.emit (node:events:519:28)\\n at addChunk (node:internal/streams/readable:561:12)\\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\\n at Socket.Readable.push (node:internal/streams/readable:392:5)\\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)\', DEFAULT, DEFAULT, \'{\\"projectId\\":1,\\"originatorId\\":41,\\"typeId\\":1,\\"year\\":2025,\\"customTokens\\":{\\"TYPE_CODE\\":\\"RFA\\",\\"ORG_CODE\\":\\"ORG\\"}}\', DEFAULT) RETURNING `id`, `error_at`'
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'error_at' in 'RETURNING'",
sql: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, \'doc_num:1:1:0:2025\', \'DB_ERROR\', \'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\', \'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\\n at Query.onResult (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\\\src\\\\driver\\\\mysql\\\\MysqlQueryRunner.ts:248:33)\\n at Query.execute (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\commands\\\\command.js:36:14)\\n at PoolConnection.handlePacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:477:34)\\n at PacketParser.onPacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:93:12)\\n at PacketParser.executeStart (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\packet_parser.js:75:16)\\n at Socket.<anonymous> (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:100:25)\\n at Socket.emit (node:events:519:28)\\n at addChunk (node:internal/streams/readable:561:12)\\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\\n at Socket.Readable.push (node:internal/streams/readable:392:5)\\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)\', DEFAULT, DEFAULT, \'{\\"projectId\\":1,\\"originatorId\\":41,\\"typeId\\":1,\\"year\\":2025,\\"customTokens\\":{\\"TYPE_CODE\\":\\"RFA\\",\\"ORG_CODE\\":\\"ORG\\"}}\', DEFAULT) RETURNING `id`, `error_at`'
}
[Nest] 22264 - 12/09/2025, 10:27:45 AM ERROR [CorrespondenceService] Failed to create correspondence: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
[Nest] 22264 - 12/09/2025, 10:27:45 AM ERROR [ExceptionsHandler] QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`',
parameters: [
1,
41,
-1,
1,
0,
0,
0,
2025,
1
],
driverError: Error: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
},
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `document_number_counters_ibfk_3` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
}
FAIL test/phase3-workflow.e2e-spec.ts (7.412 s)
ΓùÅ Console
console.warn
Skipping action test - no instanceId from submit
104 | // Skip if submit failed to get instanceId
105 | if (!workflowInstanceId) {
> 106 | console.warn('Skipping action test - no instanceId from submit');
| ^
107 | return;
108 | }
109 |
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:106:15)
● Phase 3 Workflow (E2E) › /correspondences (POST) - Create Document
expected 201 "Created", got 500 "Internal Server Error"
77 | details: { question: 'Testing Unified Workflow' },
78 | })
> 79 | .expect(201);
| ^
80 |
81 | expect(response.body).toHaveProperty('id');
82 | expect(response.body).toHaveProperty('correspondenceNumber');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:79:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
expected 201 "Created", got 400 "Bad Request"
92 | note: 'Submitting for E2E test',
93 | })
> 94 | .expect(201);
| ^
95 |
96 | expect(response.body).toHaveProperty('instanceId');
97 | expect(response.body).toHaveProperty('currentState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:94:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 2 failed, 3 passed, 5 total
Snapshots: 0 total
Time: 8.723 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

220
backend/e2e-output7.txt Normal file
View File

@@ -0,0 +1,220 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 44520 - 12/09/2025, 11:16:08 AM ERROR [DocumentNumberingService] Error generating number for doc_num:1:1:0:2025
[Nest] 44520 - 12/09/2025, 11:16:08 AM ERROR [DocumentNumberingService] QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`',
parameters: [
1,
41,
-1,
1,
0,
0,
0,
2025,
1
],
driverError: Error: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
},
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
}
[Nest] 44520 - 12/09/2025, 11:16:08 AM ERROR [DocumentNumberingService] Failed to log error
[Nest] 44520 - 12/09/2025, 11:16:08 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'error_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, DEFAULT) RETURNING `id`, `error_at`',
parameters: [
'doc_num:1:1:0:2025',
'DB_ERROR',
'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\n at Query.onResult (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\src\\driver\\mysql\\MysqlQueryRunner.ts:248:33)\n at Query.execute (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\commands\\command.js:36:14)\n at PoolConnection.handlePacket (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:477:34)\n at PacketParser.onPacket (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:93:12)\n at PacketParser.executeStart (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\packet_parser.js:75:16)\n at Socket.<anonymous> (D:\\nap-dms.lcbp3\\node_modules\\.pnpm\\mysql2@3.15.3\\node_modules\\mysql2\\lib\\base\\connection.js:100:25)\n at Socket.emit (node:events:519:28)\n at addChunk (node:internal/streams/readable:561:12)\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\n at Socket.Readable.push (node:internal/streams/readable:392:5)\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)',
'{"projectId":1,"originatorId":41,"typeId":1,"year":2025,"customTokens":{"TYPE_CODE":"RFA","ORG_CODE":"ORG"}}'
],
driverError: Error: Unknown column 'error_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'error_at' in 'RETURNING'",
sql: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, \'doc_num:1:1:0:2025\', \'DB_ERROR\', \'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\', \'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\\n at Query.onResult (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\\\src\\\\driver\\\\mysql\\\\MysqlQueryRunner.ts:248:33)\\n at Query.execute (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\commands\\\\command.js:36:14)\\n at PoolConnection.handlePacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:477:34)\\n at PacketParser.onPacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:93:12)\\n at PacketParser.executeStart (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\packet_parser.js:75:16)\\n at Socket.<anonymous> (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:100:25)\\n at Socket.emit (node:events:519:28)\\n at addChunk (node:internal/streams/readable:561:12)\\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\\n at Socket.Readable.push (node:internal/streams/readable:392:5)\\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)\', DEFAULT, DEFAULT, \'{\\"projectId\\":1,\\"originatorId\\":41,\\"typeId\\":1,\\"year\\":2025,\\"customTokens\\":{\\"TYPE_CODE\\":\\"RFA\\",\\"ORG_CODE\\":\\"ORG\\"}}\', DEFAULT) RETURNING `id`, `error_at`'
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'error_at' in 'RETURNING'",
sql: 'INSERT INTO `document_number_errors`(`id`, `counter_key`, `error_type`, `error_message`, `stack_trace`, `user_id`, `ip_address`, `context`, `error_at`) VALUES (DEFAULT, \'doc_num:1:1:0:2025\', \'DB_ERROR\', \'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\', \'QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)\\n at Query.onResult (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\\\\src\\\\driver\\\\mysql\\\\MysqlQueryRunner.ts:248:33)\\n at Query.execute (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\commands\\\\command.js:36:14)\\n at PoolConnection.handlePacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:477:34)\\n at PacketParser.onPacket (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:93:12)\\n at PacketParser.executeStart (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\packet_parser.js:75:16)\\n at Socket.<anonymous> (D:\\\\nap-dms.lcbp3\\\\node_modules\\\\.pnpm\\\\mysql2@3.15.3\\\\node_modules\\\\mysql2\\\\lib\\\\base\\\\connection.js:100:25)\\n at Socket.emit (node:events:519:28)\\n at addChunk (node:internal/streams/readable:561:12)\\n at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)\\n at Socket.Readable.push (node:internal/streams/readable:392:5)\\n at TCP.onStreamRead (node:internal/stream_base_commons:189:23)\\n at TCP.callbackTrampoline (node:internal/async_hooks:130:17)\', DEFAULT, DEFAULT, \'{\\"projectId\\":1,\\"originatorId\\":41,\\"typeId\\":1,\\"year\\":2025,\\"customTokens\\":{\\"TYPE_CODE\\":\\"RFA\\",\\"ORG_CODE\\":\\"ORG\\"}}\', DEFAULT) RETURNING `id`, `error_at`'
}
[Nest] 44520 - 12/09/2025, 11:16:08 AM ERROR [CorrespondenceService] Failed to create correspondence: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
[Nest] 44520 - 12/09/2025, 11:16:08 AM ERROR [ExceptionsHandler] QueryFailedError: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`',
parameters: [
1,
41,
-1,
1,
0,
0,
0,
2025,
1
],
driverError: Error: Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
},
code: 'ER_NO_REFERENCED_ROW_2',
errno: 1452,
sqlState: '23000',
sqlMessage: 'Cannot add or update a child row: a foreign key constraint fails (`lcbp3_dev`.`document_number_counters`, CONSTRAINT `fk_recipient_when_not_all` FOREIGN KEY (`recipient_organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE)',
sql: 'INSERT INTO `document_number_counters`(`project_id`, `originator_organization_id`, `recipient_organization_id`, `correspondence_type_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `current_year`, `last_number`, `version`) VALUES (1, 41, -1, 1, 0, 0, 0, 2025, 1, 1) RETURNING `recipient_organization_id`, `sub_type_id`, `rfa_type_id`, `discipline_id`, `last_number`, `version`'
}
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.warn
Skipping action test - no instanceId from submit
104 | // Skip if submit failed to get instanceId
105 | if (!workflowInstanceId) {
> 106 | console.warn('Skipping action test - no instanceId from submit');
| ^
107 | return;
108 | }
109 |
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:106:15)
● Phase 3 Workflow (E2E) › /correspondences (POST) - Create Document
expected 201 "Created", got 500 "Internal Server Error"
77 | details: { question: 'Testing Unified Workflow' },
78 | })
> 79 | .expect(201);
| ^
80 |
81 | expect(response.body).toHaveProperty('id');
82 | expect(response.body).toHaveProperty('correspondenceNumber');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:79:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
expected 201 "Created", got 400 "Bad Request"
92 | note: 'Submitting for E2E test',
93 | })
> 94 | .expect(201);
| ^
95 |
96 | expect(response.body).toHaveProperty('instanceId');
97 | expect(response.body).toHaveProperty('currentState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:94:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 2 failed, 3 passed, 5 total
Snapshots: 0 total
Time: 5.786 s, estimated 8 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

111
backend/e2e-output8.txt Normal file
View File

@@ -0,0 +1,111 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 25968 - 12/09/2025, 11:19:28 AM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 25968 - 12/09/2025, 11:19:28 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0001-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
1,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0001-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 1, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0001-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 1, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
[Nest] 25968 - 12/09/2025, 11:19:28 AM ERROR [WorkflowEngineService] Transition Failed for 1215d0aa-453f-46dc-845d-0488a0213c4a: Cannot read properties of undefined (reading 'roles')
[Nest] 25968 - 12/09/2025, 11:19:28 AM ERROR [CorrespondenceWorkflowService] Failed to submit workflow: TypeError: Cannot read properties of undefined (reading 'roles')
[Nest] 25968 - 12/09/2025, 11:19:28 AM ERROR [ExceptionsHandler] TypeError: Cannot read properties of undefined (reading 'roles')
at WorkflowDslService.checkRequirements (D:\nap-dms.lcbp3\backend\src\modules\workflow-engine\workflow-dsl.service.ts:219:13)
at WorkflowDslService.evaluate (D:\nap-dms.lcbp3\backend\src\modules\workflow-engine\workflow-dsl.service.ts:178:10)
at WorkflowEngineService.processTransition (D:\nap-dms.lcbp3\backend\src\modules\workflow-engine\workflow-engine.service.ts:259:42)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at CorrespondenceWorkflowService.submitWorkflow (D:\nap-dms.lcbp3\backend\src\modules\correspondence\correspondence-workflow.service.ts:72:32)
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.log
Created Correspondence ID: 3
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.warn
Skipping action test - no instanceId from submit
104 | // Skip if submit failed to get instanceId
105 | if (!workflowInstanceId) {
> 106 | console.warn('Skipping action test - no instanceId from submit');
| ^
107 | return;
108 | }
109 |
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:106:15)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
expected 201 "Created", got 500 "Internal Server Error"
92 | note: 'Submitting for E2E test',
93 | })
> 94 | .expect(201);
| ^
95 |
96 | expect(response.body).toHaveProperty('instanceId');
97 | expect(response.body).toHaveProperty('currentState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:94:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 4 passed, 5 total
Snapshots: 0 total
Time: 5.439 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

111
backend/e2e-output9.txt Normal file
View File

@@ -0,0 +1,111 @@
> backend@1.5.1 test:e2e D:\nap-dms.lcbp3\backend
> jest --config ./test/jest-e2e.json
PASS test/simple.e2e-spec.ts
PASS test/app.e2e-spec.ts
[Nest] 35280 - 12/09/2025, 11:24:24 AM ERROR [DocumentNumberingService] Failed to log audit
[Nest] 35280 - 12/09/2025, 11:24:24 AM ERROR [DocumentNumberingService] QueryFailedError: Unknown column 'generated_at' in 'RETURNING'
at Query.onResult (D:\nap-dms.lcbp3\node_modules\.pnpm\typeorm@0.3.27_ioredis@5.8._cb81dfd56f1203fe00eb0fec5dfcce08\src\driver\mysql\MysqlQueryRunner.ts:248:33)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:36:14)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
query: 'INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, ?, ?, ?, ?, DEFAULT, DEFAULT, ?, ?, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`',
parameters: [
'ผรม.1-ผรม.1-0002-2568',
'doc_num:1:1:0:2025',
'{ORG}-{ORG}-{SEQ:4}-{YEAR}',
2,
0,
0
],
driverError: Error: Unknown column 'generated_at' in 'RETURNING'
at Packet.asError (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packets\packet.js:740:17)
at Query.execute (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\commands\command.js:29:26)
at PoolConnection.handlePacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:477:34)
at PacketParser.onPacket (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:93:12)
at PacketParser.executeStart (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\packet_parser.js:75:16)
at Socket.<anonymous> (D:\nap-dms.lcbp3\node_modules\.pnpm\mysql2@3.15.3\node_modules\mysql2\lib\base\connection.js:100:25)
at Socket.emit (node:events:519:28)
at addChunk (node:internal/streams/readable:561:12)
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
at Socket.Readable.push (node:internal/streams/readable:392:5)
at TCP.onStreamRead (node:internal/stream_base_commons:189:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0002-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 2, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
},
code: 'ER_BAD_FIELD_ERROR',
errno: 1054,
sqlState: '42S22',
sqlMessage: "Unknown column 'generated_at' in 'RETURNING'",
sql: "INSERT INTO `document_number_audit`(`id`, `generated_number`, `counter_key`, `template_used`, `sequence_number`, `user_id`, `ip_address`, `retry_count`, `lock_wait_ms`, `generated_at`) VALUES (DEFAULT, 'ผรม.1-ผรม.1-0002-2568', 'doc_num:1:1:0:2025', '{ORG}-{ORG}-{SEQ:4}-{YEAR}', 2, DEFAULT, DEFAULT, 0, 0, DEFAULT) RETURNING `id`, `retry_count`, `generated_at`"
}
[Nest] 35280 - 12/09/2025, 11:24:25 AM ERROR [WorkflowEngineService] Transition Failed for 3a51f630-c4fc-4fb4-8c2b-f1150195d8bd: Cannot read properties of undefined (reading 'roles')
[Nest] 35280 - 12/09/2025, 11:24:25 AM ERROR [CorrespondenceWorkflowService] Failed to submit workflow: TypeError: Cannot read properties of undefined (reading 'roles')
[Nest] 35280 - 12/09/2025, 11:24:25 AM ERROR [ExceptionsHandler] TypeError: Cannot read properties of undefined (reading 'roles')
at WorkflowDslService.checkRequirements (D:\nap-dms.lcbp3\backend\src\modules\workflow-engine\workflow-dsl.service.ts:219:13)
at WorkflowDslService.evaluate (D:\nap-dms.lcbp3\backend\src\modules\workflow-engine\workflow-dsl.service.ts:178:10)
at WorkflowEngineService.processTransition (D:\nap-dms.lcbp3\backend\src\modules\workflow-engine\workflow-engine.service.ts:259:42)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at CorrespondenceWorkflowService.submitWorkflow (D:\nap-dms.lcbp3\backend\src\modules\correspondence\correspondence-workflow.service.ts:73:32)
FAIL test/phase3-workflow.e2e-spec.ts
ΓùÅ Console
console.log
Created Correspondence ID: 4
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:84:13)
console.warn
Skipping action test - no instanceId from submit
104 | // Skip if submit failed to get instanceId
105 | if (!workflowInstanceId) {
> 106 | console.warn('Skipping action test - no instanceId from submit');
| ^
107 | return;
108 | }
109 |
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:106:15)
● Phase 3 Workflow (E2E) › /correspondences/:id/submit (POST) - Submit to Workflow
expected 201 "Created", got 500 "Internal Server Error"
92 | note: 'Submitting for E2E test',
93 | })
> 94 | .expect(201);
| ^
95 |
96 | expect(response.body).toHaveProperty('instanceId');
97 | expect(response.body).toHaveProperty('currentState');
at Object.<anonymous> (phase3-workflow.e2e-spec.ts:94:8)
----
at Test._assertStatus (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:309:14)
at ../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:365:13
at Test._assertFunction (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:342:13)
at Test.assert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:195:23)
at localAssert (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:138:14)
at Server.<anonymous> (../../node_modules/.pnpm/supertest@7.1.4/node_modules/supertest/lib/test.js:152:11)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 4 passed, 5 total
Snapshots: 0 total
Time: 5.652 s
Ran all test suites.
ΓÇëELIFECYCLEΓÇë Command failed with exit code 1.

View File

@@ -0,0 +1,105 @@
-- Migration: Align Schema with Documentation
-- Version: 1733800000000
-- Date: 2025-12-10
-- Description: Add missing fields and fix column lengths to match schema v1.5.1
-- ==========================================================
-- Phase 1: Organizations Table Updates
-- ==========================================================
-- Add role_id column to organizations
ALTER TABLE organizations
ADD COLUMN role_id INT NULL COMMENT 'Reference to organization_roles table';
-- Add foreign key constraint
ALTER TABLE organizations
ADD CONSTRAINT fk_organizations_role FOREIGN KEY (role_id) REFERENCES organization_roles(id) ON DELETE
SET NULL;
-- Modify organization_name length from 200 to 255
ALTER TABLE organizations
MODIFY COLUMN organization_name VARCHAR(255) NOT NULL COMMENT 'Organization name';
-- ==========================================================
-- Phase 2: Users Table Updates (Security Fields)
-- ==========================================================
-- Add failed_attempts for login tracking
ALTER TABLE users
ADD COLUMN failed_attempts INT DEFAULT 0 COMMENT 'Number of failed login attempts';
-- Add locked_until for account lockout mechanism
ALTER TABLE users
ADD COLUMN locked_until DATETIME NULL COMMENT 'Account locked until this timestamp';
-- Add last_login_at for audit trail
ALTER TABLE users
ADD COLUMN last_login_at TIMESTAMP NULL COMMENT 'Last successful login timestamp';
-- ==========================================================
-- Phase 3: Roles Table Updates
-- ==========================================================
-- Modify role_name length from 50 to 100
ALTER TABLE roles
MODIFY COLUMN role_name VARCHAR(100) NOT NULL COMMENT 'Role name';
-- ==========================================================
-- Verification Queries
-- ==========================================================
-- Verify organizations table structure
SELECT COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
IS_NULLABLE,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'organizations'
ORDER BY ORDINAL_POSITION;
-- Verify users table has new security fields
SELECT COLUMN_NAME,
DATA_TYPE,
COLUMN_DEFAULT,
IS_NULLABLE,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME IN (
'failed_attempts',
'locked_until',
'last_login_at'
)
ORDER BY ORDINAL_POSITION;
-- Verify roles table role_name length
SELECT COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'roles'
AND COLUMN_NAME = 'role_name';
-- ==========================================================
-- Rollback Script (Use if needed)
-- ==========================================================
/*
-- Rollback Phase 3: Roles
ALTER TABLE roles
MODIFY COLUMN role_name VARCHAR(50) NOT NULL;
-- Rollback Phase 2: Users
ALTER TABLE users
DROP COLUMN last_login_at,
DROP COLUMN locked_until,
DROP COLUMN failed_attempts;
-- Rollback Phase 1: Organizations
ALTER TABLE organizations
MODIFY COLUMN organization_name VARCHAR(200) NOT NULL;
ALTER TABLE organizations
DROP FOREIGN KEY fk_organizations_role;
ALTER TABLE organizations
DROP COLUMN role_id;
*/

View File

@@ -7,13 +7,15 @@
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"doc": "npx @compodoc/compodoc -p tsconfig.doc.json -s",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest --forceExit",
"test:debug-handles": "jest --detectOpenHandles",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
@@ -43,6 +45,7 @@
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.1.9", "@nestjs/websockets": "^11.1.9",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@willsoto/nestjs-prometheus": "^6.0.2",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"async-retry": "^1.3.3", "async-retry": "^1.3.3",
@@ -71,11 +74,12 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27", "typeorm": "^0.3.27",
"uuid": "^13.0.0", "uuid": "^9.0.1",
"winston": "^3.18.3", "winston": "^3.18.3",
"zod": "^4.1.13" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@compodoc/compodoc": "^1.1.32",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
@@ -93,7 +97,7 @@
"@types/opossum": "^8.1.9", "@types/opossum": "^8.1.9",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0", "@types/uuid": "^9.0.8",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",

9656
backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
> backend@1.5.1 test D:\nap-dms.lcbp3\backend
> jest --forceExit "rfa"
No tests found, exiting with code 1
Run with `--passWithNoTests` to exit with code 0
In D:\nap-dms.lcbp3\backend\src
273 files checked.
testMatch: - 0 matches
testPathIgnorePatterns: \\node_modules\\ - 273 matches
testRegex: .*\.spec\.ts$ - 15 matches
Pattern: rfa - 0 matches
ΓÇëELIFECYCLEΓÇë Test failed. See above for more details.

View File

@@ -0,0 +1,31 @@
import { DataSource } from 'typeorm';
import { databaseConfig } from '../src/config/database.config';
import * as dotenv from 'dotenv';
import { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions';
dotenv.config();
async function checkConnection() {
console.log('Checking database connection...');
console.log(`Host: ${process.env.DB_HOST}`);
console.log(`Port: ${process.env.DB_PORT}`);
console.log(`User: ${process.env.DB_USERNAME}`);
console.log(`Database: ${process.env.DB_DATABASE}`);
const dataSource = new DataSource(databaseConfig as MysqlConnectionOptions);
try {
await dataSource.initialize();
console.log('✅ Connection initialized successfully!');
const result = await dataSource.query('SHOW COLUMNS FROM rfa_types');
console.log('rfa_types columns:', result);
await dataSource.destroy();
} catch (error) {
console.error('❌ Connection failed:', error);
process.exit(1);
}
}
checkConnection();

View File

@@ -29,6 +29,8 @@ import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard';
import { AuthModule } from './common/auth/auth.module.js'; import { AuthModule } from './common/auth/auth.module.js';
import { UserModule } from './modules/user/user.module'; import { UserModule } from './modules/user/user.module';
import { ProjectModule } from './modules/project/project.module'; import { ProjectModule } from './modules/project/project.module';
import { OrganizationModule } from './modules/organization/organization.module';
import { ContractModule } from './modules/contract/contract.module';
import { MasterModule } from './modules/master/master.module'; // [NEW] ✅ เพิ่ม MasterModule import { MasterModule } from './modules/master/master.module'; // [NEW] ✅ เพิ่ม MasterModule
import { FileStorageModule } from './common/file-storage/file-storage.module.js'; import { FileStorageModule } from './common/file-storage/file-storage.module.js';
import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module'; import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module';
@@ -40,9 +42,11 @@ import { DrawingModule } from './modules/drawing/drawing.module';
import { TransmittalModule } from './modules/transmittal/transmittal.module'; import { TransmittalModule } from './modules/transmittal/transmittal.module';
import { CirculationModule } from './modules/circulation/circulation.module'; import { CirculationModule } from './modules/circulation/circulation.module';
import { NotificationModule } from './modules/notification/notification.module'; import { NotificationModule } from './modules/notification/notification.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { MonitoringModule } from './modules/monitoring/monitoring.module'; import { MonitoringModule } from './modules/monitoring/monitoring.module';
import { ResilienceModule } from './common/resilience/resilience.module'; import { ResilienceModule } from './common/resilience/resilience.module';
import { SearchModule } from './modules/search/search.module'; import { SearchModule } from './modules/search/search.module';
import { AuditLogModule } from './modules/audit-log/audit-log.module';
@Module({ @Module({
imports: [ imports: [
@@ -88,7 +92,7 @@ import { SearchModule } from './modules/search/search.module';
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
type: 'mariadb', type: 'mariadb',
host: configService.get<string>('DB_HOST'), host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'), port: configService.get<number>('DB_PORT'),
@@ -107,7 +111,7 @@ import { SearchModule } from './modules/search/search.module';
BullModule.forRootAsync({ BullModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
connection: { connection: {
host: configService.get<string>('REDIS_HOST'), host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'), port: configService.get<number>('REDIS_PORT'),
@@ -136,7 +140,10 @@ import { SearchModule } from './modules/search/search.module';
// 📦 Feature Modules // 📦 Feature Modules
AuthModule, AuthModule,
UserModule, UserModule,
UserModule,
ProjectModule, ProjectModule,
OrganizationModule,
ContractModule,
MasterModule, // ✅ [NEW] Register MasterModule here MasterModule, // ✅ [NEW] Register MasterModule here
FileStorageModule, FileStorageModule,
DocumentNumberingModule, DocumentNumberingModule,
@@ -149,6 +156,8 @@ import { SearchModule } from './modules/search/search.module';
CirculationModule, CirculationModule,
SearchModule, SearchModule,
NotificationModule, NotificationModule,
DashboardModule,
AuditLogModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@@ -1,30 +1,86 @@
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service.js'; import { UnauthorizedException } from '@nestjs/common';
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO import { AuthController } from './auth.controller';
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO import { AuthService } from './auth.service';
@Controller('auth') describe('AuthController', () => {
export class AuthController { let controller: AuthController;
constructor(private authService: AuthService) {} let mockAuthService: Partial<AuthService>;
@Post('login') beforeEach(async () => {
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto mockAuthService = {
async login(@Body() loginDto: LoginDto) { validateUser: jest.fn(),
const user = await this.authService.validateUser( login: jest.fn(),
loginDto.username, register: jest.fn(),
loginDto.password, refreshToken: jest.fn(),
); logout: jest.fn(),
};
if (!user) { const module: TestingModule = await Test.createTestingModule({
throw new UnauthorizedException('Invalid credentials'); controllers: [AuthController],
} providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
return this.authService.login(user); controller = module.get<AuthController>(AuthController);
} });
@Post('register-admin') it('should be defined', () => {
// เปลี่ยน @Body() req เป็น @Body() registerDto: RegisterDto expect(controller).toBeDefined();
async register(@Body() registerDto: RegisterDto) { });
return this.authService.register(registerDto);
} describe('login', () => {
} it('should return tokens when credentials are valid', async () => {
const loginDto = { username: 'test', password: 'password' };
const mockUser = { user_id: 1, username: 'test' };
const mockTokens = {
access_token: 'access_token',
refresh_token: 'refresh_token',
user: mockUser,
};
(mockAuthService.validateUser as jest.Mock).mockResolvedValue(mockUser);
(mockAuthService.login as jest.Mock).mockResolvedValue(mockTokens);
const result = await controller.login(loginDto);
expect(mockAuthService.validateUser).toHaveBeenCalledWith(
'test',
'password'
);
expect(mockAuthService.login).toHaveBeenCalledWith(mockUser);
expect(result).toEqual(mockTokens);
});
it('should throw UnauthorizedException when credentials are invalid', async () => {
const loginDto = { username: 'test', password: 'wrong' };
(mockAuthService.validateUser as jest.Mock).mockResolvedValue(null);
await expect(controller.login(loginDto)).rejects.toThrow(
UnauthorizedException
);
});
});
describe('register', () => {
it('should register a new user', async () => {
const registerDto = {
username: 'newuser',
password: 'password',
email: 'test@test.com',
display_name: 'Test User',
};
const mockUser = { user_id: 1, ...registerDto };
(mockAuthService.register as jest.Mock).mockResolvedValue(mockUser);
const result = await controller.register(registerDto);
expect(mockAuthService.register).toHaveBeenCalledWith(registerDto);
});
});
});

View File

@@ -11,17 +11,25 @@ import {
Req, Req,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Delete,
Param,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service.js'; import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto.js'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto.js'; import { RegisterDto } from './dto/register.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { JwtRefreshGuard } from '../guards/jwt-refresh.guard.js'; import { JwtRefreshGuard } from '../guards/jwt-refresh.guard';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import {
import { Request } from 'express'; // ✅ Import Request ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiBody,
} from '@nestjs/swagger';
import { Request } from 'express';
// สร้าง Interface สำหรับ Request ที่มี User (เพื่อให้ TS รู้จัก req.user) // สร้าง Interface สำหรับ Request ที่มี User
interface RequestWithUser extends Request { interface RequestWithUser extends Request {
user: any; user: any;
} }
@@ -34,11 +42,24 @@ export class AuthController {
@Post('login') @Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } }) @Throttle({ default: { limit: 5, ttl: 60000 } })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'เข้าสู่ระบบเพื่อรับ Access & Refresh Token' }) @ApiOperation({ summary: 'Login to get Access & Refresh Token' })
@ApiBody({ type: LoginDto })
@ApiResponse({
status: 200,
description: 'Login successful',
schema: {
type: 'object',
properties: {
access_token: { type: 'string' },
refresh_token: { type: 'string' },
user: { type: 'object' },
},
},
})
async login(@Body() loginDto: LoginDto) { async login(@Body() loginDto: LoginDto) {
const user = await this.authService.validateUser( const user = await this.authService.validateUser(
loginDto.username, loginDto.username,
loginDto.password, loginDto.password
); );
if (!user) { if (!user) {
@@ -51,7 +72,9 @@ export class AuthController {
@Post('register-admin') @Post('register-admin')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'สร้างบัญชีผู้ใช้ใหม่ (Admin Only)' }) @ApiOperation({ summary: 'Create new user (Admin Only)' })
@ApiBody({ type: RegisterDto })
@ApiResponse({ status: 201, description: 'User registered' })
async register(@Body() registerDto: RegisterDto) { async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto); return this.authService.register(registerDto);
} }
@@ -59,9 +82,20 @@ export class AuthController {
@UseGuards(JwtRefreshGuard) @UseGuards(JwtRefreshGuard)
@Post('refresh') @Post('refresh')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ขอ Access Token ใหม่ด้วย Refresh Token' }) @ApiBearerAuth()
@ApiOperation({ summary: 'Refresh Access Token using Refresh Token' })
@ApiResponse({
status: 200,
description: 'Token refreshed',
schema: {
type: 'object',
properties: {
access_token: { type: 'string' },
refresh_token: { type: 'string' },
},
},
})
async refresh(@Req() req: RequestWithUser) { async refresh(@Req() req: RequestWithUser) {
// ✅ ระบุ Type ชัดเจน
return this.authService.refreshToken(req.user.sub, req.user.refreshToken); return this.authService.refreshToken(req.user.sub, req.user.refreshToken);
} }
@@ -69,23 +103,51 @@ export class AuthController {
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'ออกจากระบบ (Revoke Token)' }) @ApiOperation({ summary: 'Logout (Revoke Tokens)' })
@ApiResponse({
status: 200,
description: 'Logged out successfully',
schema: {
type: 'object',
properties: {
message: { type: 'string', example: 'Logged out successfully' },
},
},
})
async logout(@Req() req: RequestWithUser) { async logout(@Req() req: RequestWithUser) {
// ✅ ระบุ Type ชัดเจน
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
// ต้องเช็คว่ามี token หรือไม่ เพื่อป้องกัน runtime error
if (!token) { if (!token) {
return { message: 'No token provided' }; return { message: 'No token provided' };
} }
// ส่ง refresh token ไปด้วยถ้ามี (ใน header หรือ body)
// สำหรับตอนนี้ส่งแค่ access token ไป blacklist
return this.authService.logout(req.user.sub, token); return this.authService.logout(req.user.sub, token);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('profile') @Get('profile')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'ดึงข้อมูลผู้ใช้ปัจจุบัน' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'User profile' })
getProfile(@Req() req: RequestWithUser) { getProfile(@Req() req: RequestWithUser) {
// ✅ ระบุ Type ชัดเจน
return req.user; return req.user;
} }
@UseGuards(JwtAuthGuard)
@Get('sessions')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get active sessions' })
@ApiResponse({ status: 200, description: 'List of active sessions' })
async getSessions() {
return this.authService.getActiveSessions();
}
@UseGuards(JwtAuthGuard)
@Delete('sessions/:id')
@ApiBearerAuth()
@ApiOperation({ summary: 'Revoke session' })
@ApiResponse({ status: 200, description: 'Session revoked' })
async revokeSession(@Param('id') id: string) {
return this.authService.revokeSession(parseInt(id));
}
} }

View File

@@ -1,6 +1,7 @@
// File: src/common/auth/auth.module.ts // File: src/common/auth/auth.module.ts
// บันทึกการแก้ไข: แก้ไข Type Mismatch ของ expiresIn (Fix TS2322) // บันทึกการแก้ไข: แก้ไข Type Mismatch ของ expiresIn (Fix TS2322)
// [P0-1] เพิ่ม CASL RBAC Integration // [P0-1] เพิ่ม CASL RBAC Integration
// [P2-2] Register RefreshToken Entity
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@@ -13,18 +14,19 @@ import { UserModule } from '../../modules/user/user.module.js';
import { JwtStrategy } from './strategies/jwt.strategy.js'; import { JwtStrategy } from './strategies/jwt.strategy.js';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js'; import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
import { User } from '../../modules/user/entities/user.entity'; import { User } from '../../modules/user/entities/user.entity';
import { CaslModule } from './casl/casl.module'; // [P0-1] Import CASL import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
import { PermissionsGuard } from './guards/permissions.guard'; // [P0-1] Import Guard import { CaslModule } from './casl/casl.module';
import { PermissionsGuard } from './guards/permissions.guard';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([User]), TypeOrmModule.forFeature([User, RefreshToken]), // [P2-2] Added RefreshToken
UserModule, UserModule,
PassportModule, PassportModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'), secret: configService.get<string>('JWT_SECRET'),
signOptions: { signOptions: {
expiresIn: (configService.get<string>('JWT_EXPIRATION') || expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
@@ -32,18 +34,10 @@ import { PermissionsGuard } from './guards/permissions.guard'; // [P0-1] Import
}, },
}), }),
}), }),
CaslModule, // [P0-1] Import CASL module CaslModule,
],
providers: [
AuthService,
JwtStrategy,
JwtRefreshStrategy,
PermissionsGuard, // [P0-1] Register PermissionsGuard
], ],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy, PermissionsGuard],
controllers: [AuthController], controllers: [AuthController],
exports: [ exports: [AuthService, PermissionsGuard],
AuthService,
PermissionsGuard, // [P0-1] Export for use in other modules
],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -1,18 +1,201 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { UserService } from '../../modules/user/user.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from '../../modules/user/entities/user.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { Repository } from 'typeorm';
import { UnauthorizedException } from '@nestjs/common';
// Mock bcrypt at top level
jest.mock('bcrypt', () => ({
compare: jest.fn(),
hash: jest.fn().mockResolvedValue('hashedpassword'),
genSalt: jest.fn().mockResolvedValue('salt'),
}));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const bcrypt = require('bcrypt');
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
let userService: UserService;
let jwtService: JwtService;
let tokenRepo: Repository<RefreshToken>;
const mockUser = {
user_id: 1,
username: 'testuser',
password: 'hashedpassword',
primaryOrganizationId: 1,
};
const mockQueryBuilder = {
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(mockUser),
};
const mockUserRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};
const mockTokenRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
// Reset bcrypt mocks
bcrypt.compare.mockResolvedValue(true);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [AuthService], providers: [
AuthService,
{
provide: UserService,
useValue: {
findOneByUsername: jest.fn(),
create: jest.fn(),
findOne: jest.fn(),
},
},
{
provide: JwtService,
useValue: {
signAsync: jest.fn().mockResolvedValue('jwt_token'),
decode: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string) => {
if (key.includes('EXPIRATION')) return '1h';
return 'secret';
}),
},
},
{
provide: CACHE_MANAGER,
useValue: {
set: jest.fn(),
},
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepo,
},
{
provide: getRepositoryToken(RefreshToken),
useValue: mockTokenRepo,
},
],
}).compile(); }).compile();
service = module.get<AuthService>(AuthService); service = module.get<AuthService>(AuthService);
userService = module.get<UserService>(UserService);
jwtService = module.get<JwtService>(JwtService);
tokenRepo = module.get(getRepositoryToken(RefreshToken));
});
afterEach(() => {
jest.clearAllMocks();
}); });
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('validateUser', () => {
it('should return user without password if validation succeeds', async () => {
const result = await service.validateUser('testuser', 'password');
expect(result).toBeDefined();
expect(result).not.toHaveProperty('password');
expect(result.username).toBe('testuser');
});
it('should return null if user not found', async () => {
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
const result = await service.validateUser('unknown', 'password');
expect(result).toBeNull();
});
it('should return null if password mismatch', async () => {
bcrypt.compare.mockResolvedValueOnce(false);
const result = await service.validateUser('testuser', 'wrongpassword');
expect(result).toBeNull();
});
});
describe('login', () => {
it('should return access and refresh tokens', async () => {
mockTokenRepo.create.mockReturnValue({ id: 1 });
mockTokenRepo.save.mockResolvedValue({ id: 1 });
const result = await service.login(mockUser);
expect(result).toHaveProperty('access_token');
expect(result).toHaveProperty('refresh_token');
expect(mockTokenRepo.save).toHaveBeenCalled();
});
});
describe('register', () => {
it('should register a new user', async () => {
(userService.findOneByUsername as jest.Mock).mockResolvedValue(null);
(userService.create as jest.Mock).mockResolvedValue(mockUser);
const dto = {
username: 'newuser',
password: 'password',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
};
const result = await service.register(dto);
expect(result).toBeDefined();
expect(userService.create).toHaveBeenCalled();
});
});
describe('refreshToken', () => {
it('should return new tokens if valid', async () => {
const mockStoredToken = {
tokenHash: 'somehash',
isRevoked: false,
expiresAt: new Date(Date.now() + 10000),
};
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
(userService.findOne as jest.Mock).mockResolvedValue(mockUser);
const result = await service.refreshToken(1, 'valid_refresh_token');
expect(result.access_token).toBeDefined();
expect(result.refresh_token).toBeDefined();
// Should mark old token as revoked
expect(mockTokenRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ isRevoked: true })
);
});
it('should throw UnauthorizedException if token revoked', async () => {
const mockStoredToken = {
tokenHash: 'somehash',
isRevoked: true,
expiresAt: new Date(Date.now() + 10000),
};
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
await expect(service.refreshToken(1, 'revoked_token')).rejects.toThrow(
UnauthorizedException
);
});
});
}); });

View File

@@ -2,6 +2,7 @@
// บันทึกการแก้ไข: // บันทึกการแก้ไข:
// 1. แก้ไข Type Mismatch ใน signAsync // 1. แก้ไข Type Mismatch ใน signAsync
// 2. แก้ไข validateUser ให้ดึง password_hash ออกมาด้วย (Fix HTTP 500: data and hash arguments required) // 2. แก้ไข validateUser ให้ดึง password_hash ออกมาด้วย (Fix HTTP 500: data and hash arguments required)
// 3. [P2-2] Implement Refresh Token storage & rotation
import { import {
Injectable, Injectable,
@@ -12,14 +13,16 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { InjectRepository } from '@nestjs/typeorm'; // [NEW] import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; // [NEW] import { Repository } from 'typeorm';
import type { Cache } from 'cache-manager'; import type { Cache } from 'cache-manager';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { UserService } from '../../modules/user/user.service.js'; import { UserService } from '../../modules/user/user.service';
import { User } from '../../modules/user/entities/user.entity.js'; // [NEW] ต้อง Import Entity เพื่อใช้ Repository import { User } from '../../modules/user/entities/user.entity';
import { RegisterDto } from './dto/register.dto.js'; import { RegisterDto } from './dto/register.dto';
import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -28,31 +31,27 @@ export class AuthService {
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService, private configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, @Inject(CACHE_MANAGER) private cacheManager: Cache,
// [NEW] Inject Repository เพื่อใช้ QueryBuilder
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, private usersRepository: Repository<User>,
// [P2-2] Inject RefreshToken Repository
@InjectRepository(RefreshToken)
private refreshTokenRepository: Repository<RefreshToken>
) {} ) {}
// 1. ตรวจสอบ Username/Password // 1. ตรวจสอบ Username/Password
async validateUser(username: string, pass: string): Promise<any> { async validateUser(username: string, pass: string): Promise<any> {
console.log(`🔍 Checking login for: ${username}`); // [DEBUG] console.log(`🔍 Checking login for: ${username}`);
// [FIXED] ใช้ createQueryBuilder เพื่อ addSelect field 'password' ที่ถูกซ่อนไว้
const user = await this.usersRepository const user = await this.usersRepository
.createQueryBuilder('user') .createQueryBuilder('user')
.addSelect('user.password') // สำคัญ! สั่งให้ดึง column password มาด้วย .addSelect('user.password')
.where('user.username = :username', { username }) .where('user.username = :username', { username })
.getOne(); .getOne();
if (!user) { if (!user) {
console.log('❌ User not found in database'); // [DEBUG] console.log('❌ User not found in database');
return null; return null;
} }
console.log('✅ User found. Hash from DB:', user.password); // [DEBUG]
const isMatch = await bcrypt.compare(pass, user.password);
console.log(`🔐 Password match result: ${isMatch}`); // [DEBUG]
// ตรวจสอบว่ามี user และมี password hash หรือไม่ // ตรวจสอบว่ามี user และมี password hash หรือไม่
if (user && user.password && (await bcrypt.compare(pass, user.password))) { if (user && user.password && (await bcrypt.compare(pass, user.password))) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -62,7 +61,7 @@ export class AuthService {
return null; return null;
} }
// 2. Login: สร้าง Access & Refresh Token // 2. Login: สร้าง Access & Refresh Token และบันทึกลง DB
async login(user: any) { async login(user: any) {
const payload = { const payload = {
username: user.username, username: user.username,
@@ -70,20 +69,20 @@ export class AuthService {
scope: 'Global', scope: 'Global',
}; };
const [accessToken, refreshToken] = await Promise.all([ const accessToken = await this.jwtService.signAsync(payload, {
this.jwtService.signAsync(payload, { secret: this.configService.get<string>('JWT_SECRET'),
secret: this.configService.get<string>('JWT_SECRET'), expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
// ✅ Fix: Cast as any '15m') as any,
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') || });
'15m') as any,
}), const refreshToken = await this.jwtService.signAsync(payload, {
this.jwtService.signAsync(payload, { secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
secret: this.configService.get<string>('JWT_REFRESH_SECRET'), expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') ||
// ✅ Fix: Cast as any '7d') as any,
expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') || });
'7d') as any,
}), // [P2-2] Store Refresh Token in DB
]); await this.storeRefreshToken(user.user_id, refreshToken);
return { return {
access_token: accessToken, access_token: accessToken,
@@ -92,10 +91,28 @@ export class AuthService {
}; };
} }
// [P2-2] Store Refresh Token Logic
private async storeRefreshToken(userId: number, token: string) {
// Hash token before storing for security
const hash = crypto.createHash('sha256').update(token).digest('hex');
const expiresInDays = 7; // Should match JWT_REFRESH_EXPIRATION
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
const refreshTokenEntity = this.refreshTokenRepository.create({
userId,
tokenHash: hash,
expiresAt,
isRevoked: false,
});
await this.refreshTokenRepository.save(refreshTokenEntity);
}
// 3. Register (สำหรับ Admin) // 3. Register (สำหรับ Admin)
async register(userDto: RegisterDto) { async register(userDto: RegisterDto) {
const existingUser = await this.userService.findOneByUsername( const existingUser = await this.userService.findOneByUsername(
userDto.username, userDto.username
); );
if (existingUser) { if (existingUser) {
throw new BadRequestException('Username already exists'); throw new BadRequestException('Username already exists');
@@ -110,27 +127,79 @@ export class AuthService {
}); });
} }
// 4. Refresh Token: ออก Token ใหม่ // 4. Refresh Token: ตรวจสอบและออก Token ใหม่ (Rotation)
async refreshToken(userId: number, refreshToken: string) { async refreshToken(userId: number, refreshToken: string) {
// Hash incoming token to match with DB
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
// Find token in DB
const storedToken = await this.refreshTokenRepository.findOne({
where: { tokenHash: hash },
});
if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token');
}
if (storedToken.isRevoked) {
// Possible token theft! Invalidate all user tokens family
await this.revokeAllUserTokens(userId);
throw new UnauthorizedException('Refresh token revoked - Security alert');
}
if (storedToken.expiresAt < new Date()) {
throw new UnauthorizedException('Refresh token expired');
}
// Valid token -> Rotate it
const user = await this.userService.findOne(userId); const user = await this.userService.findOne(userId);
if (!user) throw new UnauthorizedException('User not found'); if (!user) throw new UnauthorizedException('User not found');
const payload = { username: user.username, sub: user.user_id }; const payload = { username: user.username, sub: user.user_id };
const accessToken = await this.jwtService.signAsync(payload, { // Generate NEW tokens
const newAccessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_SECRET'), secret: this.configService.get<string>('JWT_SECRET'),
// ✅ Fix: Cast as any
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') || expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
'15m') as any, '15m') as any,
}); });
const newRefreshToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') ||
'7d') as any,
});
// Revoke OLD token and point to NEW one
const newHash = crypto
.createHash('sha256')
.update(newRefreshToken)
.digest('hex');
storedToken.isRevoked = true;
storedToken.replacedByToken = newHash;
await this.refreshTokenRepository.save(storedToken);
// Save NEW token
await this.storeRefreshToken(userId, newRefreshToken);
return { return {
access_token: accessToken, access_token: newAccessToken,
refresh_token: newRefreshToken,
}; };
} }
// 5. Logout: นำ Token เข้า Blacklist ใน Redis // [P2-2] Helper: Revoke all tokens for a user (Security Measure)
async logout(userId: number, accessToken: string) { private async revokeAllUserTokens(userId: number) {
await this.refreshTokenRepository.update(
{ userId, isRevoked: false },
{ isRevoked: true }
);
}
// 5. Logout: Revoke current refresh token & Blacklist Access Token
async logout(userId: number, accessToken: string, refreshToken?: string) {
// Blacklist Access Token
try { try {
const decoded = this.jwtService.decode(accessToken); const decoded = this.jwtService.decode(accessToken);
if (decoded && decoded.exp) { if (decoded && decoded.exp) {
@@ -139,13 +208,65 @@ export class AuthService {
await this.cacheManager.set( await this.cacheManager.set(
`blacklist:token:${accessToken}`, `blacklist:token:${accessToken}`,
true, true,
ttl * 1000, ttl * 1000
); );
} }
} }
} catch (error) { } catch (error) {
// Ignore decoding error // Ignore decoding error
} }
// [P2-2] Revoke Refresh Token if provided
if (refreshToken) {
const hash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
await this.refreshTokenRepository.update(
{ tokenHash: hash },
{ isRevoked: true }
);
}
return { message: 'Logged out successfully' }; return { message: 'Logged out successfully' };
} }
// [New] Get Active Sessions
async getActiveSessions() {
// Only return tokens that are NOT revoked and NOT expired
const activeTokens = await this.refreshTokenRepository.find({
where: {
isRevoked: false,
},
relations: ['user'], // Ensure relations: ['user'] works if RefreshToken entity has relation
order: { createdAt: 'DESC' },
});
const now = new Date();
// Filter expired tokens in memory if query builder is complex, or rely on where clause if possible.
// Filter expired tokens
return activeTokens
.filter((t) => new Date(t.expiresAt) > now)
.map((t) => ({
id: t.tokenId.toString(),
userId: t.userId,
user: {
username: t.user?.username || 'Unknown',
firstName: t.user?.firstName || '',
lastName: t.user?.lastName || '',
},
deviceName: 'Unknown Device', // Not stored in DB
ipAddress: 'Unknown IP', // Not stored in DB
lastActive: t.createdAt.toISOString(), // Best approximation
isCurrent: false, // Cannot determine isCurrent without current session context match
}));
}
// [New] Revoke Session by ID
async revokeSession(sessionId: number) {
return this.refreshTokenRepository.update(
{ tokenId: sessionId },
{ isRevoked: true }
);
}
} }

View File

@@ -1,11 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import { Ability, AbilityBuilder, AbilityClass } from '@casl/ability';
Ability,
AbilityBuilder,
AbilityClass,
ExtractSubjectType,
InferSubjects,
} from '@casl/ability';
import { User } from '../../../modules/user/entities/user.entity'; import { User } from '../../../modules/user/entities/user.entity';
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity'; import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
@@ -45,7 +39,7 @@ export class AbilityFactory {
* - Level 4: Contract * - Level 4: Contract
*/ */
createForUser(user: User, context: ScopeContext): AppAbility { createForUser(user: User, context: ScopeContext): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>( const { can, build } = new AbilityBuilder<AppAbility>(
Ability as AbilityClass<AppAbility> Ability as AbilityClass<AppAbility>
); );
@@ -54,12 +48,13 @@ export class AbilityFactory {
return build(); return build();
} }
// Iterate through user's role assignments
// Iterate through user's role assignments // Iterate through user's role assignments
user.assignments.forEach((assignment: UserAssignment) => { user.assignments.forEach((assignment: UserAssignment) => {
// Check if assignment matches the current context // Check if assignment matches the current context
if (this.matchesScope(assignment, context)) { if (this.matchesScope(assignment, context)) {
// Grant permissions from the role // Grant permissions from the role
assignment.role.permissions.forEach((permission) => { assignment.role.permissions?.forEach((permission) => {
const [action, subject] = this.parsePermission( const [action, subject] = this.parsePermission(
permission.permissionName permission.permissionName
); );
@@ -70,8 +65,10 @@ export class AbilityFactory {
return build({ return build({
// Detect subject type (for future use with objects) // Detect subject type (for future use with objects)
detectSubjectType: (item) => detectSubjectType: (item: any) => {
item.constructor as ExtractSubjectType<Subjects>, if (typeof item === 'string') return item;
return item.constructor;
},
}); });
} }
@@ -120,17 +117,17 @@ export class AbilityFactory {
* "project.view" → ["view", "project"] * "project.view" → ["view", "project"]
*/ */
private parsePermission(permissionName: string): [string, string] { private parsePermission(permissionName: string): [string, string] {
// Fallback for special permissions like "system.manage_all"
if (permissionName === 'system.manage_all') {
return ['manage', 'all'];
}
const parts = permissionName.split('.'); const parts = permissionName.split('.');
if (parts.length === 2) { if (parts.length === 2) {
const [subject, action] = parts; const [subject, action] = parts;
return [action, subject]; return [action, subject];
} }
// Fallback for special permissions like "system.manage_all"
if (permissionName === 'system.manage_all') {
return ['manage', 'all'];
}
throw new Error(`Invalid permission format: ${permissionName}`); throw new Error(`Invalid permission format: ${permissionName}`);
} }
} }

View File

@@ -1,10 +1,16 @@
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto { export class LoginDto {
@ApiProperty({
description: 'Username (Email)',
example: 'admin@np-dms.work',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
username!: string; username!: string;
@ApiProperty({ description: 'Password', example: 'password123' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
password!: string; password!: string;

View File

@@ -0,0 +1,38 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../../modules/user/entities/user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn({ name: 'token_id' })
tokenId!: number;
@Column({ name: 'user_id' })
userId!: number;
@Column({ name: 'token_hash', length: 255 })
tokenHash!: string;
@Column({ name: 'expires_at' })
expiresAt!: Date;
@Column({ name: 'is_revoked', default: false })
isRevoked!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@Column({ name: 'replaced_by_token', nullable: true, length: 255 })
replacedByToken?: string; // For rotation support
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
}

View File

@@ -43,7 +43,7 @@ export class PermissionsGuard implements CanActivate {
// Check if user has ALL required permissions // Check if user has ALL required permissions
const hasPermission = requiredPermissions.every((permission) => { const hasPermission = requiredPermissions.every((permission) => {
const [action, subject] = this.parsePermission(permission); const [action, subject] = this.parsePermission(permission);
return ability.can(action, subject); return ability.can(action as any, subject as any);
}); });
if (!hasPermission) { if (!hasPermission) {

View File

@@ -47,9 +47,9 @@ export class AuditLog {
@Column({ name: 'user_agent', length: 255, nullable: true }) @Column({ name: 'user_agent', length: 255, nullable: true })
userAgent?: string; userAgent?: string;
// ✅ [Fix] รวม Decorator ไว้ที่นี่ที่เดียว // ✅ [Fix] ทั้งสอง Decorator ต้องระบุ name: 'created_at'
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
@PrimaryColumn() // เพื่อบอกว่าเป็น Composite PK คู่กับ auditId @PrimaryColumn({ name: 'created_at' }) // Composite PK คู่กับ auditId
createdAt!: Date; createdAt!: Date;
// Relations // Relations

View File

@@ -1,20 +1,15 @@
import { import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm';
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
} from 'typeorm';
export abstract class BaseEntity { export abstract class BaseEntity {
// @PrimaryGeneratedColumn() // @PrimaryGeneratedColumn()
// id!: number; // id!: number;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
created_at!: Date; createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' }) @UpdateDateColumn({ name: 'updated_at' })
updated_at!: Date; updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', select: false }) // select: false เพื่อซ่อน field นี้โดย Default @DeleteDateColumn({ name: 'deleted_at', select: false })
deleted_at!: Date; deletedAt?: Date;
} }

View File

@@ -6,7 +6,7 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { User } from '../../../modules/user/entities/user.entity.js'; import { User } from '../../../modules/user/entities/user.entity';
@Entity('attachments') @Entity('attachments')
export class Attachment { export class Attachment {

View File

@@ -1,12 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageController } from './file-storage.controller'; import { FileStorageController } from './file-storage.controller';
import { FileStorageService } from './file-storage.service';
describe('FileStorageController', () => { describe('FileStorageController', () => {
let controller: FileStorageController; let controller: FileStorageController;
let mockFileStorageService: Partial<FileStorageService>;
beforeEach(async () => { beforeEach(async () => {
mockFileStorageService = {
upload: jest.fn(),
download: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [FileStorageController], controllers: [FileStorageController],
providers: [
{
provide: FileStorageService,
useValue: mockFileStorageService,
},
],
}).compile(); }).compile();
controller = module.get<FileStorageController>(FileStorageController); controller = module.get<FileStorageController>(FileStorageController);
@@ -15,4 +29,25 @@ describe('FileStorageController', () => {
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); expect(controller).toBeDefined();
}); });
describe('uploadFile', () => {
it('should upload a file successfully', async () => {
const mockFile = {
originalname: 'test.pdf',
buffer: Buffer.from('test'),
mimetype: 'application/pdf',
size: 100,
} as Express.Multer.File;
const mockResult = { attachment_id: 1, originalFilename: 'test.pdf' };
(mockFileStorageService.upload as jest.Mock).mockResolvedValue(
mockResult
);
const mockReq = { user: { userId: 1, username: 'testuser' } };
const result = await controller.uploadFile(mockFile, mockReq as any);
expect(mockFileStorageService.upload).toHaveBeenCalledWith(mockFile, 1);
});
});
}); });

View File

@@ -18,8 +18,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { FileStorageService } from './file-storage.service.js'; import { FileStorageService } from './file-storage.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
// Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว // Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว
interface RequestWithUser { interface RequestWithUser {
@@ -47,10 +47,10 @@ export class FileStorageController {
/(pdf|msword|openxmlformats|zip|octet-stream|image|jpeg|png)/, /(pdf|msword|openxmlformats|zip|octet-stream|image|jpeg|png)/,
}), }),
], ],
}), })
) )
file: Express.Multer.File, file: Express.Multer.File,
@Request() req: RequestWithUser, @Request() req: RequestWithUser
) { ) {
// ส่ง userId จาก Token ไปด้วย // ส่ง userId จาก Token ไปด้วย
return this.fileStorageService.upload(file, req.user.userId); return this.fileStorageService.upload(file, req.user.userId);
@@ -63,7 +63,7 @@ export class FileStorageController {
@Get(':id/download') @Get(':id/download')
async downloadFile( async downloadFile(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const { stream, attachment } = await this.fileStorageService.download(id); const { stream, attachment } = await this.fileStorageService.download(id);
@@ -87,7 +87,7 @@ export class FileStorageController {
@Delete(':id') @Delete(':id')
async deleteFile( async deleteFile(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Request() req: RequestWithUser, @Request() req: RequestWithUser
) { ) {
// ส่ง userId ไปด้วยเพื่อตรวจสอบความเป็นเจ้าของ // ส่ง userId ไปด้วยเพื่อตรวจสอบความเป็นเจ้าของ
await this.fileStorageService.delete(id, req.user.userId); await this.fileStorageService.delete(id, req.user.userId);

View File

@@ -4,7 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule'; // ✅ Import
import { FileStorageService } from './file-storage.service.js'; import { FileStorageService } from './file-storage.service.js';
import { FileStorageController } from './file-storage.controller.js'; import { FileStorageController } from './file-storage.controller.js';
import { FileCleanupService } from './file-cleanup.service.js'; // ✅ Import import { FileCleanupService } from './file-cleanup.service.js'; // ✅ Import
import { Attachment } from './entities/attachment.entity.js'; import { Attachment } from './entities/attachment.entity';
@Module({ @Module({
imports: [ imports: [

View File

@@ -1,18 +1,142 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageService } from './file-storage.service'; import { FileStorageService } from './file-storage.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Attachment } from './entities/attachment.entity';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs-extra';
import {
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { Repository } from 'typeorm';
// Mock fs-extra
jest.mock('fs-extra');
describe('FileStorageService', () => { describe('FileStorageService', () => {
let service: FileStorageService; let service: FileStorageService;
let attachmentRepo: Repository<Attachment>;
const mockAttachment = {
id: 1,
originalFilename: 'test.pdf',
storedFilename: 'uuid.pdf',
filePath: '/permanent/2024/12/uuid.pdf',
fileSize: 1024,
uploadedByUserId: 1,
} as Attachment;
const mockFile = {
originalname: 'test.pdf',
mimetype: 'application/pdf',
size: 1024,
buffer: Buffer.from('test-content'),
} as Express.Multer.File;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [FileStorageService], providers: [
FileStorageService,
{
provide: getRepositoryToken(Attachment),
useValue: {
create: jest.fn().mockReturnValue(mockAttachment),
save: jest.fn().mockResolvedValue(mockAttachment),
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn((key) => {
if (key === 'NODE_ENV') return 'test';
return null;
}),
},
},
],
}).compile(); }).compile();
service = module.get<FileStorageService>(FileStorageService); service = module.get<FileStorageService>(FileStorageService);
attachmentRepo = module.get(getRepositoryToken(Attachment));
jest.clearAllMocks();
(fs.ensureDirSync as jest.Mock).mockReturnValue(true);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.pathExists as jest.Mock).mockResolvedValue(true);
(fs.move as jest.Mock).mockResolvedValue(undefined);
(fs.remove as jest.Mock).mockResolvedValue(undefined);
}); });
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('upload', () => {
it('should save file to temp and create DB record', async () => {
const result = await service.upload(mockFile, 1);
expect(fs.writeFile).toHaveBeenCalled();
expect(attachmentRepo.create).toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should throw BadRequestException if write fails', async () => {
(fs.writeFile as jest.Mock).mockRejectedValueOnce(
new Error('Write error')
);
await expect(service.upload(mockFile, 1)).rejects.toThrow(
BadRequestException
);
});
});
describe('commit', () => {
it('should move files to permanent storage', async () => {
const tempIds = ['uuid-1'];
const mockAttachments = [
{
...mockAttachment,
isTemporary: true,
tempId: 'uuid-1',
filePath: '/temp/uuid.pdf',
},
];
(attachmentRepo.find as jest.Mock).mockResolvedValue(mockAttachments);
await service.commit(tempIds);
expect(fs.ensureDir).toHaveBeenCalled();
expect(fs.move).toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalled();
});
it('should show warning if file counts mismatch', async () => {
(attachmentRepo.find as jest.Mock).mockResolvedValue([]);
await expect(service.commit(['uuid-1'])).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete file if user owns it', async () => {
(attachmentRepo.findOne as jest.Mock).mockResolvedValue(mockAttachment);
await service.delete(1, 1);
expect(fs.remove).toHaveBeenCalled();
expect(attachmentRepo.remove).toHaveBeenCalled();
});
it('should throw ForbiddenException if user does not own file', async () => {
(attachmentRepo.findOne as jest.Mock).mockResolvedValue(mockAttachment);
await expect(service.delete(1, 999)).rejects.toThrow(ForbiddenException);
});
});
}); });

View File

@@ -12,7 +12,7 @@ import * as fs from 'fs-extra';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Attachment } from './entities/attachment.entity.js'; import { Attachment } from './entities/attachment.entity';
import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม
@Injectable() @Injectable()

View File

@@ -5,25 +5,25 @@ import {
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { PERMISSION_KEY } from '../decorators/require-permission.decorator.js'; import { PERMISSIONS_KEY } from '../decorators/require-permission.decorator';
import { UserService } from '../../modules/user/user.service.js'; import { UserService } from '../../modules/user/user.service';
@Injectable() @Injectable()
export class RbacGuard implements CanActivate { export class RbacGuard implements CanActivate {
constructor( constructor(
private reflector: Reflector, private reflector: Reflector,
private userService: UserService, private userService: UserService
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. ดูว่า Controller นี้ต้องการสิทธิ์อะไร? // 1. ดูว่า Controller นี้ต้องการสิทธิ์อะไร?
const requiredPermission = this.reflector.getAllAndOverride<string>( const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
PERMISSION_KEY, PERMISSIONS_KEY,
[context.getHandler(), context.getClass()], [context.getHandler(), context.getClass()]
); );
// ถ้าไม่ต้องการสิทธิ์อะไรเลย ก็ปล่อยผ่าน // ถ้าไม่ต้องการสิทธิ์อะไรเลย ก็ปล่อยผ่าน
if (!requiredPermission) { if (!requiredPermissions || requiredPermissions.length === 0) {
return true; return true;
} }
@@ -34,19 +34,20 @@ export class RbacGuard implements CanActivate {
} }
// 3. (สำคัญ) ดึงสิทธิ์ทั้งหมดของ User คนนี้จาก Database // 3. (สำคัญ) ดึงสิทธิ์ทั้งหมดของ User คนนี้จาก Database
// เราต้องเขียนฟังก์ชัน getUserPermissions ใน UserService เพิ่ม (เดี๋ยวพาทำ)
const userPermissions = await this.userService.getUserPermissions( const userPermissions = await this.userService.getUserPermissions(
user.userId, user.user_id // ✅ FIX: ใช้ user_id ตาม Entity field name
); );
// 4. ตรวจสอบว่ามีสิทธิ์ที่ต้องการไหม? // 4. ตรวจสอบว่ามีสิทธิ์ที่ต้องการไหม? (User ต้องมีครบทุกสิทธิ์)
const hasPermission = userPermissions.some( const hasPermission = requiredPermissions.every((req) =>
(p) => p === requiredPermission || p === 'system.manage_all', // Superadmin ทะลุทุกสิทธิ์ userPermissions.some(
(p) => p === req || p === 'system.manage_all' // Superadmin ทะลุทุกสิทธิ์
)
); );
if (!hasPermission) { if (!hasPermission) {
throw new ForbiddenException( throw new ForbiddenException(
`You do not have permission: ${requiredPermission}`, `You do not have permission: ${requiredPermissions.join(', ')}`
); );
} }

View File

@@ -11,6 +11,7 @@ export interface Response<T> {
statusCode: number; statusCode: number;
message: string; message: string;
data: T; data: T;
meta?: any;
} }
@Injectable() @Injectable()
@@ -19,14 +20,29 @@ export class TransformInterceptor<T>
{ {
intercept( intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler, next: CallHandler
): Observable<Response<T>> { ): Observable<Response<T>> {
return next.handle().pipe( return next.handle().pipe(
map((data) => ({ map((data: any) => {
statusCode: context.switchToHttp().getResponse().statusCode, const response = context.switchToHttp().getResponse();
message: data?.message || 'Success', // ถ้า data มี message ให้ใช้ ถ้าไม่มีใช้ 'Success'
data: data?.result || data, // รองรับกรณีส่ง object ที่มี key result มา // Handle Pagination Response (Standardize)
})), // ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา
if (data && data.data && data.meta) {
return {
statusCode: response.statusCode,
message: data.message || 'Success',
data: data.data,
meta: data.meta,
};
}
return {
statusCode: response.statusCode,
message: data?.message || 'Success',
data: data?.result || data,
};
})
); );
} }
} }

View File

@@ -1,5 +1,5 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Organization } from '../../modules/organizations/entities/organization.entity'; import { Organization } from '../../modules/organization/entities/organization.entity';
export async function seedOrganizations(dataSource: DataSource) { export async function seedOrganizations(dataSource: DataSource) {
const repo = dataSource.getRepository(Organization); const repo = dataSource.getRepository(Organization);

View File

@@ -1,45 +1,54 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { User } from '../../modules/users/entities/user.entity'; import { User } from '../../modules/user/entities/user.entity';
import { Role } from '../../modules/auth/entities/role.entity'; import { Role, RoleScope } from '../../modules/user/entities/role.entity';
import { UserAssignment } from '../../modules/user/entities/user-assignment.entity';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
export async function seedUsers(dataSource: DataSource) { export async function seedUsers(dataSource: DataSource) {
const userRepo = dataSource.getRepository(User); const userRepo = dataSource.getRepository(User);
const roleRepo = dataSource.getRepository(Role); const roleRepo = dataSource.getRepository(Role);
const assignmentRepo = dataSource.getRepository(UserAssignment);
// Create Roles // Create Roles
const rolesData = [ const rolesData = [
{ {
roleName: 'Superadmin', roleName: 'Superadmin',
scope: RoleScope.GLOBAL,
description: description:
'ผู้ดูแลระบบสูงสุด: สามารถทำทุกอย่างในระบบ, จัดการองค์กร, และจัดการข้อมูลหลักระดับ Global', 'ผู้ดูแลระบบสูงสุด: สามารถทำทุกอย่างในระบบ, จัดการองค์กร, และจัดการข้อมูลหลักระดับ Global',
}, },
{ {
roleName: 'Org Admin', roleName: 'Org Admin',
scope: RoleScope.ORGANIZATION,
description: description:
'ผู้ดูแลองค์กร: จัดการผู้ใช้ในองค์กร, จัดการบทบาท / สิทธิ์ภายในองค์กร, และดูรายงานขององค์กร', 'ผู้ดูแลองค์กร: จัดการผู้ใช้ในองค์กร, จัดการบทบาท / สิทธิ์ภายในองค์กร, และดูรายงานขององค์กร',
}, },
{ {
roleName: 'Document Control', roleName: 'Document Control',
scope: RoleScope.ORGANIZATION,
description: description:
'ควบคุมเอกสารขององค์กร: เพิ่ม / แก้ไข / ลบเอกสาร, และกำหนดสิทธิ์เอกสารภายในองค์กร', 'ควบคุมเอกสารขององค์กร: เพิ่ม / แก้ไข / ลบเอกสาร, และกำหนดสิทธิ์เอกสารภายในองค์กร',
}, },
{ {
roleName: 'Editor', roleName: 'Editor',
scope: RoleScope.PROJECT,
description: description:
'ผู้แก้ไขเอกสารขององค์กร: เพิ่ม / แก้ไขเอกสารที่ได้รับมอบหมาย', 'ผู้แก้ไขเอกสารขององค์กร: เพิ่ม / แก้ไขเอกสารที่ได้รับมอบหมาย',
}, },
{ {
roleName: 'Viewer', roleName: 'Viewer',
scope: RoleScope.PROJECT,
description: 'ผู้ดูเอกสารขององค์กร: ดูเอกสารที่มีสิทธิ์เข้าถึงเท่านั้น', description: 'ผู้ดูเอกสารขององค์กร: ดูเอกสารที่มีสิทธิ์เข้าถึงเท่านั้น',
}, },
{ {
roleName: 'Project Manager', roleName: 'Project Manager',
scope: RoleScope.PROJECT,
description: description:
'ผู้จัดการโครงการ: จัดการสมาชิกในโครงการ, สร้าง / จัดการสัญญาในโครงการ, และดูรายงานโครงการ', 'ผู้จัดการโครงการ: จัดการสมาชิกในโครงการ, สร้าง / จัดการสัญญาในโครงการ, และดูรายงานโครงการ',
}, },
{ {
roleName: 'Contract Admin', roleName: 'Contract Admin',
scope: RoleScope.CONTRACT,
description: description:
'ผู้ดูแลสัญญา: จัดการสมาชิกในสัญญา, สร้าง / จัดการข้อมูลหลักเฉพาะสัญญา, และอนุมัติเอกสารในสัญญา', 'ผู้ดูแลสัญญา: จัดการสมาชิกในสัญญา, สร้าง / จัดการข้อมูลหลักเฉพาะสัญญา, และอนุมัติเอกสารในสัญญา',
}, },
@@ -49,6 +58,7 @@ export async function seedUsers(dataSource: DataSource) {
for (const r of rolesData) { for (const r of rolesData) {
let role = await roleRepo.findOneBy({ roleName: r.roleName }); let role = await roleRepo.findOneBy({ roleName: r.roleName });
if (!role) { if (!role) {
// @ts-ignore
role = await roleRepo.save(roleRepo.create(r)); role = await roleRepo.save(roleRepo.create(r));
} }
roleMap.set(r.roleName, role); roleMap.set(r.roleName, role);
@@ -87,20 +97,30 @@ export async function seedUsers(dataSource: DataSource) {
]; ];
const salt = await bcrypt.genSalt(); const salt = await bcrypt.genSalt();
const passwordHash = await bcrypt.hash('password123', salt); // Default password const password = await bcrypt.hash('password123', salt); // Default password
for (const u of usersData) { for (const u of usersData) {
const exists = await userRepo.findOneBy({ username: u.username }); let user = await userRepo.findOneBy({ username: u.username });
if (!exists) { if (!user) {
const user = userRepo.create({ user = userRepo.create({
username: u.username, username: u.username,
email: u.email, email: u.email,
firstName: u.firstName, firstName: u.firstName,
lastName: u.lastName, lastName: u.lastName,
passwordHash, password, // Fixed: password instead of passwordHash
roles: [roleMap.get(u.roleName)],
}); });
await userRepo.save(user); user = await userRepo.save(user);
// Create Assignment
const role = roleMap.get(u.roleName);
if (role) {
const assignment = assignmentRepo.create({
user,
role,
assignedAt: new Date(),
});
await assignmentRepo.save(assignment);
}
} }
} }
} }

View File

@@ -22,11 +22,24 @@ async function bootstrap() {
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
// 🛡️ 2. Security (Helmet & CORS) // 🛡️ 2. Security (Helmet & CORS)
app.use(helmet()); // ปรับ CSP ให้รองรับ Swagger UI
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
},
},
crossOriginEmbedderPolicy: false,
})
);
// ตั้งค่า CORS (ใน Production ควรระบุ origin ให้ชัดเจนจาก Config) // ตั้งค่า CORS (ใน Production ควรระบุ origin ให้ชัดเจนจาก Config)
app.enableCors({ app.enableCors({
origin: true, // หรือ configService.get('CORS_ORIGIN') origin: configService.get<string>('CORS_ORIGIN') || true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, credentials: true,
}); });
@@ -47,7 +60,7 @@ async function bootstrap() {
transformOptions: { transformOptions: {
enableImplicitConversion: true, // ช่วยแปลง Type ใน Query Params enableImplicitConversion: true, // ช่วยแปลง Type ใน Query Params
}, },
}), })
); );
// ลงทะเบียน Global Interceptor และ Filter ที่เราสร้างไว้ // ลงทะเบียน Global Interceptor และ Filter ที่เราสร้างไว้
@@ -73,9 +86,9 @@ async function bootstrap() {
// 🚀 7. Start Server // 🚀 7. Start Server
const port = configService.get<number>('PORT') || 3001; const port = configService.get<number>('PORT') || 3001;
await app.listen(port); await app.listen(port, '0.0.0.0');
logger.log(`Application is running on: ${await app.getUrl()}/api`); logger.log(`Application is running on: ${await app.getUrl()}/api`);
logger.log(`Swagger UI is available at: ${await app.getUrl()}/docs`); logger.log(`Swagger UI is available at: ${await app.getUrl()}/docs`);
} }
bootstrap(); void bootstrap();

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { AuditLogService } from './audit-log.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, RbacGuard)
export class AuditLogController {
constructor(private readonly auditLogService: AuditLogService) {}
@Get()
@RequirePermission('audit-log.view')
findAll(@Query() query: any) {
return this.auditLogService.findAll(query);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLogController } from './audit-log.controller';
import { AuditLogService } from './audit-log.service';
import { AuditLog } from '../../common/entities/audit-log.entity';
import { UserModule } from '../user/user.module';
@Module({
imports: [TypeOrmModule.forFeature([AuditLog]), UserModule],
controllers: [AuditLogController],
providers: [AuditLogService],
exports: [AuditLogService],
})
export class AuditLogModule {}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from '../../common/entities/audit-log.entity';
@Injectable()
export class AuditLogService {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepository: Repository<AuditLog>
) {}
async findAll(query: any) {
const { page = 1, limit = 20, entityName, action, userId } = query;
const skip = (page - 1) * limit;
const queryBuilder =
this.auditLogRepository.createQueryBuilder('audit_logs'); // Aliased as 'audit_logs' matching table name usually, or just 'log'
if (entityName) {
queryBuilder.andWhere('audit_logs.entityName LIKE :entityName', {
entityName: `%${entityName}%`,
});
}
if (action) {
queryBuilder.andWhere('audit_logs.action = :action', { action });
}
if (userId) {
queryBuilder.andWhere('audit_logs.userId = :userId', { userId });
}
queryBuilder.orderBy('audit_logs.createdAt', 'DESC').skip(skip).take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
meta: {
total,
page: Number(page),
limit: Number(limit),
totalPages: Math.ceil(total / limit),
},
};
}
}

View File

@@ -5,36 +5,55 @@ import {
ManyToMany, ManyToMany,
JoinTable, JoinTable,
} from 'typeorm'; } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity';
@Entity('permissions') @Entity('permissions')
export class Permission { export class Permission extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn({ name: 'permission_id' })
id!: number; id!: number;
@Column({ name: 'permission_code', length: 50, unique: true }) @Column({ name: 'permission_name', length: 100, unique: true })
permissionCode!: string; permissionName!: string;
@Column({ name: 'description', type: 'text', nullable: true }) @Column({ name: 'description', type: 'text', nullable: true })
description!: string; description!: string;
@Column({ name: 'resource', length: 50 }) @Column({ name: 'module', length: 50, nullable: true })
resource!: string; module?: string;
@Column({ name: 'action', length: 50 }) @Column({
action!: string; name: 'scope_level',
type: 'enum',
enum: ['GLOBAL', 'ORG', 'PROJECT'],
nullable: true,
})
scopeLevel?: 'GLOBAL' | 'ORG' | 'PROJECT';
@Column({ name: 'is_active', default: true, type: 'tinyint' })
isActive!: boolean;
} }
@Entity('roles') @Entity('roles')
export class Role { export class Role extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn({ name: 'role_id' })
id!: number; id!: number;
@Column({ name: 'role_name', length: 50, unique: true }) @Column({ name: 'role_name', length: 100, unique: true })
roleName!: string; roleName!: string;
@Column({ name: 'description', type: 'text', nullable: true }) @Column({ name: 'description', type: 'text', nullable: true })
description!: string; description!: string;
@Column({
type: 'enum',
enum: ['Global', 'Organization', 'Project', 'Contract'],
default: 'Global',
})
scope!: 'Global' | 'Organization' | 'Project' | 'Contract';
@Column({ name: 'is_system', default: false })
isSystem!: boolean;
@ManyToMany(() => Permission) @ManyToMany(() => Permission)
@JoinTable({ @JoinTable({
name: 'role_permissions', name: 'role_permissions',

View File

@@ -13,7 +13,7 @@ import { User } from '../user/entities/user.entity';
import { CreateCirculationDto } from './dto/create-circulation.dto'; import { CreateCirculationDto } from './dto/create-circulation.dto';
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto'; import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto';
import { SearchCirculationDto } from './dto/search-circulation.dto'; import { SearchCirculationDto } from './dto/search-circulation.dto';
import { DocumentNumberingService } from '../document-numbering/document-numbering.service'; import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
@Injectable() @Injectable()
export class CirculationService { export class CirculationService {
@@ -37,9 +37,9 @@ export class CirculationService {
try { try {
// Generate No. using DocumentNumberingService (Type 900 - Circulation) // Generate No. using DocumentNumberingService (Type 900 - Circulation)
const circulationNo = await this.numberingService.generateNextNumber({ const result = await this.numberingService.generateNextNumber({
projectId: createDto.projectId || 0, // Use projectId from DTO or 0 projectId: createDto.projectId || 0, // Use projectId from DTO or 0
originatorId: user.primaryOrganizationId, originatorOrganizationId: user.primaryOrganizationId,
typeId: 900, // Fixed Type ID for Circulation typeId: 900, // Fixed Type ID for Circulation
year: new Date().getFullYear(), year: new Date().getFullYear(),
customTokens: { customTokens: {
@@ -51,7 +51,7 @@ export class CirculationService {
const circulation = queryRunner.manager.create(Circulation, { const circulation = queryRunner.manager.create(Circulation, {
organizationId: user.primaryOrganizationId, organizationId: user.primaryOrganizationId,
correspondenceId: createDto.correspondenceId, correspondenceId: createDto.correspondenceId,
circulationNo: circulationNo, circulationNo: result.number,
subject: createDto.subject, subject: createDto.subject,
statusCode: 'OPEN', statusCode: 'OPEN',
createdByUserId: user.user_id, createdByUserId: user.user_id,

View File

@@ -8,7 +8,7 @@ import {
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Circulation } from './circulation.entity'; import { Circulation } from './circulation.entity';
import { Organization } from '../../project/entities/organization.entity'; import { Organization } from '../../organization/entities/organization.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
@Entity('circulation_routings') @Entity('circulation_routings')

View File

@@ -9,7 +9,7 @@ import {
OneToMany, OneToMany,
} from 'typeorm'; } from 'typeorm';
import { Correspondence } from '../../correspondence/entities/correspondence.entity'; import { Correspondence } from '../../correspondence/entities/correspondence.entity';
import { Organization } from '../../project/entities/organization.entity'; import { Organization } from '../../organization/entities/organization.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { CirculationStatusCode } from './circulation-status-code.entity'; import { CirculationStatusCode } from './circulation-status-code.entity';
import { CirculationRouting } from './circulation-routing.entity'; import { CirculationRouting } from './circulation-routing.entity';

View File

@@ -19,6 +19,7 @@ import {
import { ContractService } from './contract.service.js'; import { ContractService } from './contract.service.js';
import { CreateContractDto } from './dto/create-contract.dto.js'; import { CreateContractDto } from './dto/create-contract.dto.js';
import { UpdateContractDto } from './dto/update-contract.dto.js'; import { UpdateContractDto } from './dto/update-contract.dto.js';
import { SearchContractDto } from './dto/search-contract.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
@@ -38,11 +39,10 @@ export class ContractController {
@Get() @Get()
@ApiOperation({ @ApiOperation({
summary: 'Get All Contracts (Optional: filter by projectId)', summary: 'Get All Contracts (Search & Filter)',
}) })
@ApiQuery({ name: 'projectId', required: false, type: Number }) findAll(@Query() query: SearchContractDto) {
findAll(@Query('projectId') projectId?: number) { return this.contractService.findAll(query);
return this.contractService.findAll(projectId);
} }
@Get(':id') @Get(':id')

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractService } from './contract.service';
import { ContractController } from './contract.controller';
import { Contract } from './entities/contract.entity';
import { ContractOrganization } from './entities/contract-organization.entity';
import { ProjectModule } from '../project/project.module'; // Likely needed for Project entity or service
@Module({
imports: [
TypeOrmModule.forFeature([Contract, ContractOrganization]),
ProjectModule,
],
controllers: [ContractController],
providers: [ContractService],
exports: [ContractService],
})
export class ContractModule {}

View File

@@ -4,8 +4,8 @@ import {
ConflictException, ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, Like } from 'typeorm';
import { Contract } from './entities/contract.entity.js'; import { Contract } from './entities/contract.entity';
import { CreateContractDto } from './dto/create-contract.dto.js'; import { CreateContractDto } from './dto/create-contract.dto.js';
import { UpdateContractDto } from './dto/update-contract.dto.js'; import { UpdateContractDto } from './dto/update-contract.dto.js';
@@ -29,17 +29,53 @@ export class ContractService {
return this.contractRepo.save(contract); return this.contractRepo.save(contract);
} }
async findAll(projectId?: number) { async findAll(params?: any) {
const query = this.contractRepo const { search, projectId, page = 1, limit = 100 } = params || {};
.createQueryBuilder('c') const skip = (page - 1) * limit;
.leftJoinAndSelect('c.project', 'p')
.orderBy('c.contractCode', 'ASC');
if (projectId) { const findOptions: any = {
query.where('c.projectId = :projectId', { projectId }); relations: ['project'],
order: { contractCode: 'ASC' },
skip,
take: limit,
where: [],
};
const searchConditions = [];
if (search) {
searchConditions.push({ contractCode: Like(`%${search}%`) });
searchConditions.push({ contractName: Like(`%${search}%`) });
} }
return query.getMany(); if (projectId) {
// Combine project filter with search if exists
if (searchConditions.length > 0) {
findOptions.where = searchConditions.map((cond) => ({
...cond,
projectId,
}));
} else {
findOptions.where = { projectId };
}
} else {
if (searchConditions.length > 0) {
findOptions.where = searchConditions;
} else {
delete findOptions.where; // No filters
}
}
const [data, total] = await this.contractRepo.findAndCount(findOptions);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
} }
async findOne(id: number) { async findOne(id: number) {

View File

@@ -0,0 +1,30 @@
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchContractDto {
@ApiPropertyOptional({ description: 'Search term (code or name)' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'Filter by Project ID' })
@IsOptional()
@IsInt()
@Type(() => Number)
projectId?: number;
@ApiPropertyOptional({ description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number = 1;
@ApiPropertyOptional({ description: 'Items per page', default: 100 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
limit?: number = 100;
}

View File

@@ -1,6 +1,6 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Contract } from './contract.entity.js'; import { Contract } from './contract.entity';
import { Organization } from './organization.entity.js'; import { Organization } from '../../organization/entities/organization.entity';
@Entity('contract_organizations') @Entity('contract_organizations')
export class ContractOrganization { export class ContractOrganization {

View File

@@ -5,8 +5,8 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity.js'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Project } from './project.entity.js'; import { Project } from '../../project/entities/project.entity';
@Entity('contracts') @Entity('contracts')
export class Contract extends BaseEntity { export class Contract extends BaseEntity {

View File

@@ -23,13 +23,14 @@ export class CorrespondenceWorkflowService {
private readonly revisionRepo: Repository<CorrespondenceRevision>, private readonly revisionRepo: Repository<CorrespondenceRevision>,
@InjectRepository(CorrespondenceStatus) @InjectRepository(CorrespondenceStatus)
private readonly statusRepo: Repository<CorrespondenceStatus>, private readonly statusRepo: Repository<CorrespondenceStatus>,
private readonly dataSource: DataSource, private readonly dataSource: DataSource
) {} ) {}
async submitWorkflow( async submitWorkflow(
correspondenceId: number, correspondenceId: number,
userId: number, userId: number,
note?: string, userRoles: string[], // [FIX] Added roles for DSL requirements check
note?: string
) { ) {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
@@ -44,7 +45,7 @@ export class CorrespondenceWorkflowService {
if (!revision) { if (!revision) {
throw new NotFoundException( throw new NotFoundException(
`Correspondence Revision for ID ${correspondenceId} not found`, `Correspondence Revision for ID ${correspondenceId} not found`
); );
} }
@@ -66,7 +67,7 @@ export class CorrespondenceWorkflowService {
this.WORKFLOW_CODE, this.WORKFLOW_CODE,
'correspondence_revision', 'correspondence_revision',
revision.id.toString(), revision.id.toString(),
context, context
); );
const transitionResult = await this.workflowEngine.processTransition( const transitionResult = await this.workflowEngine.processTransition(
@@ -74,7 +75,7 @@ export class CorrespondenceWorkflowService {
'SUBMIT', 'SUBMIT',
userId, userId,
note || 'Initial Submission', note || 'Initial Submission',
{}, { roles: userRoles } // [FIX] Pass roles for DSL requirements check
); );
await this.syncStatus(revision, transitionResult.nextState, queryRunner); await this.syncStatus(revision, transitionResult.nextState, queryRunner);
@@ -97,14 +98,14 @@ export class CorrespondenceWorkflowService {
async processAction( async processAction(
instanceId: string, instanceId: string,
userId: number, userId: number,
dto: WorkflowTransitionDto, dto: WorkflowTransitionDto
) { ) {
const result = await this.workflowEngine.processTransition( const result = await this.workflowEngine.processTransition(
instanceId, instanceId,
dto.action, dto.action,
userId, userId,
dto.comment, dto.comment,
dto.payload, dto.payload
); );
// ✅ FIX: Method exists now // ✅ FIX: Method exists now
@@ -125,7 +126,7 @@ export class CorrespondenceWorkflowService {
private async syncStatus( private async syncStatus(
revision: CorrespondenceRevision, revision: CorrespondenceRevision,
workflowState: string, workflowState: string,
queryRunner?: any, queryRunner?: any
) { ) {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
DRAFT: 'DRAFT', DRAFT: 'DRAFT',

View File

@@ -1,13 +1,48 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { CorrespondenceController } from './correspondence.controller'; import { CorrespondenceController } from './correspondence.controller';
import { CorrespondenceService } from './correspondence.service';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
describe('CorrespondenceController', () => { describe('CorrespondenceController', () => {
let controller: CorrespondenceController; let controller: CorrespondenceController;
let mockCorrespondenceService: Partial<CorrespondenceService>;
let mockWorkflowService: Partial<CorrespondenceWorkflowService>;
beforeEach(async () => { beforeEach(async () => {
mockCorrespondenceService = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
getReferences: jest.fn(),
addReference: jest.fn(),
removeReference: jest.fn(),
};
mockWorkflowService = {
submitWorkflow: jest.fn(),
processAction: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [CorrespondenceController], controllers: [CorrespondenceController],
}).compile(); providers: [
{
provide: CorrespondenceService,
useValue: mockCorrespondenceService,
},
{
provide: CorrespondenceWorkflowService,
useValue: mockWorkflowService,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RbacGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<CorrespondenceController>(CorrespondenceController); controller = module.get<CorrespondenceController>(CorrespondenceController);
}); });
@@ -15,4 +50,68 @@ describe('CorrespondenceController', () => {
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); expect(controller).toBeDefined();
}); });
describe('findAll', () => {
it('should return correspondences', async () => {
const mockResult = [{ id: 1 }];
(mockCorrespondenceService.findAll as jest.Mock).mockResolvedValue(
mockResult
);
const result = await controller.findAll({});
expect(mockCorrespondenceService.findAll).toHaveBeenCalled();
expect(result).toEqual(mockResult);
});
});
describe('create', () => {
it('should create a correspondence', async () => {
const mockCorr = { id: 1, correspondenceNumber: 'TEST-001' };
(mockCorrespondenceService.create as jest.Mock).mockResolvedValue(
mockCorr
);
const mockReq = { user: { user_id: 1 } };
const createDto = {
projectId: 1,
typeId: 1,
title: 'Test Subject',
};
const result = await controller.create(
createDto as Parameters<typeof controller.create>[0],
mockReq as Parameters<typeof controller.create>[1]
);
expect(mockCorrespondenceService.create).toHaveBeenCalledWith(
createDto,
mockReq.user
);
});
});
describe('submit', () => {
it('should submit a correspondence to workflow', async () => {
const mockResult = { instanceId: 'inst-1', currentState: 'IN_REVIEW' };
(mockWorkflowService.submitWorkflow as jest.Mock).mockResolvedValue(
mockResult
);
const mockReq = { user: { user_id: 1 } };
const result = await controller.submit(
1,
{ note: 'Test note' },
mockReq as Parameters<typeof controller.submit>[2]
);
expect(mockWorkflowService.submitWorkflow).toHaveBeenCalledWith(
1,
1,
[],
'Test note'
);
expect(result).toEqual(mockResult);
});
});
}); });

View File

@@ -5,91 +5,209 @@ import {
Body, Body,
UseGuards, UseGuards,
Request, Request,
Param, // <--- ✅ 1. เพิ่ม Param Param,
ParseIntPipe, // <--- ✅ 2. เพิ่ม ParseIntPipe ParseIntPipe,
Query,
Delete,
Put,
} from '@nestjs/common'; } from '@nestjs/common';
import { CorrespondenceService } from './correspondence.service.js'; import {
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js'; ApiTags,
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto.js'; // <--- ✅ 3. เพิ่ม Import DTO นี้ ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { CorrespondenceService } from './correspondence.service';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { UpdateCorrespondenceDto } from './dto/update-correspondence.dto';
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto';
import { WorkflowActionDto } from './dto/workflow-action.dto';
import { AddReferenceDto } from './dto/add-reference.dto';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard.js'; import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator';
import { WorkflowActionDto } from './dto/workflow-action.dto.js';
// ... imports ...
import { AddReferenceDto } from './dto/add-reference.dto.js';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto.js';
import { Query, Delete } from '@nestjs/common'; // เพิ่ม Query, Delete
import { Audit } from '../../common/decorators/audit.decorator'; // Import
@ApiTags('Correspondences')
@Controller('correspondences') @Controller('correspondences')
@UseGuards(JwtAuthGuard, RbacGuard) @UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
export class CorrespondenceController { export class CorrespondenceController {
constructor(private readonly correspondenceService: CorrespondenceService) {} constructor(
private readonly correspondenceService: CorrespondenceService,
private readonly workflowService: CorrespondenceWorkflowService
) {}
@Post(':id/workflow/action') @Post(':id/workflow/action')
@RequirePermission('workflow.action_review') // สิทธิ์ในการกดอนุมัติ/ตรวจสอบ @ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
@RequirePermission('workflow.action_review')
processAction( processAction(
@Param('id', ParseIntPipe) id: number,
@Body() actionDto: WorkflowActionDto, @Body() actionDto: WorkflowActionDto,
@Request() req: any, @Request()
req: Request & {
user: {
user_id: number;
assignments?: Array<{ role: { roleName: string } }>;
};
}
) { ) {
return this.correspondenceService.processAction(id, actionDto, req.user); // Extract roles from user assignments for DSL requirements check
const userRoles =
req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || [];
// Use Unified Workflow Engine via CorrespondenceWorkflowService
if (!actionDto.instanceId) {
throw new Error('instanceId is required for workflow action');
}
return this.workflowService.processAction(
actionDto.instanceId,
req.user.user_id,
{
action: actionDto.action,
comment: actionDto.comment,
payload: { ...actionDto.payload, roles: userRoles },
}
);
} }
@Post() @Post()
@RequirePermission('correspondence.create') // 🔒 ต้องมีสิทธิ์สร้าง @ApiOperation({ summary: 'Create new correspondence' })
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้ @ApiResponse({
create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) { status: 201,
return this.correspondenceService.create(createDto, req.user); description: 'Correspondence created successfully.',
type: CreateCorrespondenceDto,
})
@RequirePermission('correspondence.create')
@Audit('correspondence.create', 'correspondence')
create(
@Body() createDto: CreateCorrespondenceDto,
@Request() req: Request & { user: unknown }
) {
return this.correspondenceService.create(
createDto,
req.user as Parameters<typeof this.correspondenceService.create>[1]
);
}
@Post('preview-number')
@ApiOperation({ summary: 'Preview next document number' })
@ApiResponse({
status: 200,
description: 'Return preview number and status.',
})
@RequirePermission('correspondence.create')
previewNumber(
@Body() createDto: CreateCorrespondenceDto,
@Request() req: Request & { user: unknown }
) {
return this.correspondenceService.previewDocumentNumber(
createDto,
req.user as Parameters<typeof this.correspondenceService.create>[1]
);
} }
// ✅ ปรับปรุง findAll ให้รับ Query Params
@Get() @Get()
@ApiOperation({ summary: 'Search correspondences' })
@ApiResponse({ status: 200, description: 'Return list of correspondences.' })
@RequirePermission('document.view') @RequirePermission('document.view')
findAll(@Query() searchDto: SearchCorrespondenceDto) { findAll(@Query() searchDto: SearchCorrespondenceDto) {
return this.correspondenceService.findAll(searchDto); return this.correspondenceService.findAll(searchDto);
} }
// ✅ เพิ่ม Endpoint นี้ครับ
@Post(':id/submit') @Post(':id/submit')
@RequirePermission('correspondence.create') // หรือจะสร้าง Permission ใหม่ 'workflow.submit' ก็ได้ @ApiOperation({ summary: 'Submit correspondence to Unified Workflow Engine' })
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้ @ApiResponse({
status: 201,
description: 'Correspondence submitted successfully.',
})
@RequirePermission('correspondence.create')
@Audit('correspondence.submit', 'correspondence')
submit( submit(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() submitDto: SubmitCorrespondenceDto, @Body() submitDto: SubmitCorrespondenceDto,
@Request() req: any, @Request()
req: Request & {
user: {
user_id: number;
assignments?: Array<{ role: { roleName: string } }>;
};
}
) { ) {
return this.correspondenceService.submit( // Extract roles from user assignments
const userRoles =
req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || [];
// Use Unified Workflow Engine - pass user roles for DSL requirements check
return this.workflowService.submitWorkflow(
id, id,
submitDto.templateId, req.user.user_id,
req.user, userRoles,
submitDto.note
); );
} }
// --- REFERENCES --- @Get(':id')
@ApiOperation({ summary: 'Get correspondence by ID' })
@ApiResponse({ status: 200, description: 'Return correspondence details.' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.correspondenceService.findOne(id);
}
@Put(':id')
@ApiOperation({ summary: 'Update correspondence (Draft only)' })
@ApiResponse({
status: 200,
description: 'Correspondence updated successfully.',
})
@RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit'
@Audit('correspondence.update', 'correspondence')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateCorrespondenceDto,
@Request() req: Request & { user: unknown }
) {
return this.correspondenceService.update(
id,
updateDto,
req.user as Parameters<typeof this.correspondenceService.create>[1]
);
}
@Get(':id/references') @Get(':id/references')
@ApiOperation({ summary: 'Get referenced documents' })
@ApiResponse({
status: 200,
description: 'Return list of referenced documents.',
})
@RequirePermission('document.view') @RequirePermission('document.view')
getReferences(@Param('id', ParseIntPipe) id: number) { getReferences(@Param('id', ParseIntPipe) id: number) {
return this.correspondenceService.getReferences(id); return this.correspondenceService.getReferences(id);
} }
@Post(':id/references') @Post(':id/references')
@RequirePermission('document.edit') // ต้องมีสิทธิ์แก้ไขถึงจะเพิ่ม Ref ได้ @ApiOperation({ summary: 'Add reference to another document' })
@ApiResponse({ status: 201, description: 'Reference added successfully.' })
@RequirePermission('document.edit')
addReference( addReference(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: AddReferenceDto, @Body() dto: AddReferenceDto
) { ) {
return this.correspondenceService.addReference(id, dto); return this.correspondenceService.addReference(id, dto);
} }
@Delete(':id/references/:targetId') @Delete(':id/references/:targetId')
@ApiOperation({ summary: 'Remove reference' })
@ApiResponse({ status: 200, description: 'Reference removed successfully.' })
@RequirePermission('document.edit') @RequirePermission('document.edit')
removeReference( removeReference(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Param('targetId', ParseIntPipe) targetId: number, @Param('targetId', ParseIntPipe) targetId: number
) { ) {
return this.correspondenceService.removeReference(id, targetId); return this.correspondenceService.removeReference(id, targetId);
} }

View File

@@ -1,25 +1,31 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CorrespondenceController } from './correspondence.controller.js'; import { CorrespondenceController } from './correspondence.controller';
import { CorrespondenceService } from './correspondence.service.js'; import { CorrespondenceService } from './correspondence.service';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity.js'; import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { CorrespondenceType } from './entities/correspondence-type.entity.js';
import { Correspondence } from './entities/correspondence.entity.js';
// Import Entities ใหม่
import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js';
import { RoutingTemplateStep } from './entities/routing-template-step.entity.js';
import { RoutingTemplate } from './entities/routing-template.entity.js';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module.js'; // ต้องใช้ตอน Create // Entities
import { JsonSchemaModule } from '../json-schema/json-schema.module.js'; // ต้องใช้ Validate Details import { Correspondence } from './entities/correspondence.entity';
import { SearchModule } from '../search/search.module'; // ✅ 1. เพิ่ม Import SearchModule import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule import { CorrespondenceType } from './entities/correspondence-type.entity';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module.js'; // <--- ✅ เพิ่มบรรทัดนี้ครับ import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { CorrespondenceReference } from './entities/correspondence-reference.entity.js'; import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity.js'; import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
// Controllers & Services import { Organization } from '../organization/entities/organization.entity';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service'; // Register Service นี้
// Dependent Modules
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { JsonSchemaModule } from '../json-schema/json-schema.module';
import { UserModule } from '../user/user.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
import { SearchModule } from '../search/search.module';
/**
* CorrespondenceModule
*
* NOTE: RoutingTemplate and RoutingTemplateStep have been deprecated.
* All workflow operations now use the Unified Workflow Engine.
*/
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
@@ -27,19 +33,18 @@ import { CorrespondenceWorkflowService } from './correspondence-workflow.service
CorrespondenceRevision, CorrespondenceRevision,
CorrespondenceType, CorrespondenceType,
CorrespondenceStatus, CorrespondenceStatus,
RoutingTemplate, // <--- ลงทะเบียน CorrespondenceReference,
RoutingTemplateStep, // <--- ลงทะเบียน CorrespondenceRecipient,
CorrespondenceRouting, // <--- ลงทะเบียน Organization,
CorrespondenceReference, // <--- ลงทะเบียน
]), ]),
DocumentNumberingModule, // Import เพื่อขอเลขที่เอกสาร DocumentNumberingModule,
JsonSchemaModule, // Import เพื่อ Validate JSON JsonSchemaModule,
UserModule, // <--- 2. ใส่ UserModule ใน imports เพื่อให้ RbacGuard ทำงานได้ UserModule,
WorkflowEngineModule, // <--- Import WorkflowEngine WorkflowEngineModule,
SearchModule, // ✅ 2. ใส่ SearchModule ที่นี่ SearchModule,
], ],
controllers: [CorrespondenceController], controllers: [CorrespondenceController],
providers: [CorrespondenceService, CorrespondenceWorkflowService], providers: [CorrespondenceService, CorrespondenceWorkflowService],
exports: [CorrespondenceService], exports: [CorrespondenceService, CorrespondenceWorkflowService],
}) })
export class CorrespondenceModule {} export class CorrespondenceModule {}

View File

@@ -1,18 +1,270 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CorrespondenceService } from './correspondence.service'; import { CorrespondenceService } from './correspondence.service';
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { CorrespondenceType } from './entities/correspondence-type.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { Organization } from '../organization/entities/organization.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { JsonSchemaService } from '../json-schema/json-schema.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { UserService } from '../user/user.service';
import { SearchService } from '../search/search.service';
describe('CorrespondenceService', () => { describe('CorrespondenceService', () => {
let service: CorrespondenceService; let service: CorrespondenceService;
let numberingService: DocumentNumberingService;
let correspondenceRepo: any;
let revisionRepo: any;
let dataSource: any;
const createMockRepository = () => ({
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
softDelete: jest.fn(),
createQueryBuilder: jest.fn(() => ({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
getMany: jest.fn().mockResolvedValue([]),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
})),
});
const mockDataSource = {
createQueryRunner: jest.fn(() => ({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
},
})),
getRepository: jest.fn(() => createMockRepository()),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [CorrespondenceService], providers: [
CorrespondenceService,
{
provide: getRepositoryToken(Correspondence),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceRevision),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceType),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceStatus),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceReference),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(Organization),
useValue: createMockRepository(),
},
{
provide: DocumentNumberingService,
useValue: {
generateNextNumber: jest.fn(),
updateNumberForDraft: jest.fn(),
previewNextNumber: jest.fn(),
},
},
{
provide: JsonSchemaService,
useValue: { validate: jest.fn() },
},
{
provide: WorkflowEngineService,
useValue: { createInstance: jest.fn() },
},
{
provide: UserService,
useValue: {
findOne: jest.fn(),
getUserPermissions: jest.fn().mockResolvedValue([]),
},
},
{
provide: DataSource,
useValue: mockDataSource,
},
{
provide: SearchService,
useValue: { indexDocument: jest.fn() },
},
],
}).compile(); }).compile();
service = module.get<CorrespondenceService>(CorrespondenceService); service = module.get<CorrespondenceService>(CorrespondenceService);
numberingService = module.get<DocumentNumberingService>(
DocumentNumberingService
);
correspondenceRepo = module.get(getRepositoryToken(Correspondence));
revisionRepo = module.get(getRepositoryToken(CorrespondenceRevision));
dataSource = module.get(DataSource);
}); });
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('update', () => {
it('should NOT regenerate number if critical fields unchanged', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
}; // Status 5 = Draft handled by logic?
// Mock status repo to return DRAFT
// But strict logic: revision.statusId check
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
const mockStatus = { id: 5, statusCode: 'DRAFT' };
// Need to set statusRepo mock behavior... simplified here for brevity or assume defaults
// Injecting internal access to statusRepo is hard without `module.get` if I didn't save it.
// Let's assume it passes check for now.
const mockCorr = {
id: 1,
projectId: 1,
correspondenceTypeId: 2,
disciplineId: 3,
originatorId: 10,
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }],
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
// Update DTO with same values
const updateDto = {
projectId: 1,
disciplineId: 3,
// recipients missing -> imply no change
};
await service.update(1, updateDto as any, mockUser);
// Check that updateNumberForDraft was NOT called
expect(numberingService.updateNumberForDraft).not.toHaveBeenCalled();
});
it('should regenerate number if Project ID changes', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
};
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
const mockCorr = {
id: 1,
projectId: 1, // Old Project
correspondenceTypeId: 2,
disciplineId: 3,
originatorId: 10,
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }],
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
const updateDto = {
projectId: 2, // New Project -> Change!
};
await service.update(1, updateDto as any, mockUser);
expect(numberingService.updateNumberForDraft).toHaveBeenCalled();
});
it('should regenerate number if Document Type changes', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
};
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
const mockCorr = {
id: 1,
projectId: 1,
correspondenceTypeId: 2, // Old Type
disciplineId: 3,
originatorId: 10,
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }],
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
const updateDto = {
typeId: 999, // New Type
};
await service.update(1, updateDto as any, mockUser);
expect(numberingService.updateNumberForDraft).toHaveBeenCalled();
});
it('should regenerate number if Recipient Organization changes', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
};
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
const mockCorr = {
id: 1,
projectId: 1,
correspondenceTypeId: 2,
disciplineId: 3,
originatorId: 10,
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }], // Old Recipient 99
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
jest
.spyOn(service['orgRepo'], 'findOne')
.mockResolvedValue({ id: 88, organizationCode: 'NEW-ORG' } as any);
const updateDto = {
recipients: [{ type: 'TO', organizationId: 88 }], // New Recipient 88
};
await service.update(1, updateDto as any, mockUser);
expect(numberingService.updateNumberForDraft).toHaveBeenCalled();
});
});
}); });

View File

@@ -9,34 +9,38 @@ import {
Logger, Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, Like, In } from 'typeorm'; import { Repository, DataSource } from 'typeorm';
// Entitie // Entities
import { Correspondence } from './entities/correspondence.entity'; import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity'; import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { CorrespondenceType } from './entities/correspondence-type.entity'; import { CorrespondenceType } from './entities/correspondence-type.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity'; import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { RoutingTemplate } from './entities/routing-template.entity';
import { CorrespondenceRouting } from './entities/correspondence-routing.entity';
import { CorrespondenceReference } from './entities/correspondence-reference.entity'; import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { Organization } from '../organization/entities/organization.entity';
// DTOs // DTOs
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto'; import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { WorkflowActionDto } from './dto/workflow-action.dto'; import { UpdateCorrespondenceDto } from './dto/update-correspondence.dto';
import { AddReferenceDto } from './dto/add-reference.dto'; import { AddReferenceDto } from './dto/add-reference.dto';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto'; import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
import { DeepPartial } from 'typeorm';
// Interfaces & Enums
import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface';
// Services // Services
import { DocumentNumberingService } from '../document-numbering/document-numbering.service'; import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { JsonSchemaService } from '../json-schema/json-schema.service'; import { JsonSchemaService } from '../json-schema/json-schema.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { SearchService } from '../search/search.service'; import { SearchService } from '../search/search.service';
/**
* CorrespondenceService - Document management (CRUD)
*
* NOTE: Workflow operations (submit, processAction) have been moved to
* CorrespondenceWorkflowService which uses the Unified Workflow Engine.
*/
@Injectable() @Injectable()
export class CorrespondenceService { export class CorrespondenceService {
private readonly logger = new Logger(CorrespondenceService.name); private readonly logger = new Logger(CorrespondenceService.name);
@@ -50,12 +54,10 @@ export class CorrespondenceService {
private typeRepo: Repository<CorrespondenceType>, private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus) @InjectRepository(CorrespondenceStatus)
private statusRepo: Repository<CorrespondenceStatus>, private statusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(RoutingTemplate)
private templateRepo: Repository<RoutingTemplate>,
@InjectRepository(CorrespondenceRouting)
private routingRepo: Repository<CorrespondenceRouting>,
@InjectRepository(CorrespondenceReference) @InjectRepository(CorrespondenceReference)
private referenceRepo: Repository<CorrespondenceReference>, private referenceRepo: Repository<CorrespondenceReference>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
private numberingService: DocumentNumberingService, private numberingService: DocumentNumberingService,
private jsonSchemaService: JsonSchemaService, private jsonSchemaService: JsonSchemaService,
@@ -111,9 +113,9 @@ export class CorrespondenceService {
if (createDto.details) { if (createDto.details) {
try { try {
await this.jsonSchemaService.validate(type.typeCode, createDto.details); await this.jsonSchemaService.validate(type.typeCode, createDto.details);
} catch (error: any) { } catch (error: unknown) {
this.logger.warn( this.logger.warn(
`Schema validation warning for ${type.typeCode}: ${error.message}` `Schema validation warning for ${type.typeCode}: ${(error as Error).message}`
); );
} }
} }
@@ -125,24 +127,38 @@ export class CorrespondenceService {
try { try {
const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity
// [FIXED] เรียกใช้แบบ Object Context ตาม Requirement 6B // [v1.5.1] Extract recipient organization from recipients array (Primary TO)
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.orgRepo.findOne({
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
const docNumber = await this.numberingService.generateNextNumber({ const docNumber = await this.numberingService.generateNextNumber({
projectId: createDto.projectId, projectId: createDto.projectId,
originatorId: userOrgId, originatorOrganizationId: userOrgId,
typeId: createDto.typeId, typeId: createDto.typeId,
disciplineId: createDto.disciplineId, // ส่ง Discipline (ถ้ามี) disciplineId: createDto.disciplineId,
subTypeId: createDto.subTypeId, // ส่ง SubType (ถ้ามี) subTypeId: createDto.subTypeId,
recipientOrganizationId, // [v1.5.1] Pass recipient for document number format
year: new Date().getFullYear(), year: new Date().getFullYear(),
customTokens: { customTokens: {
TYPE_CODE: type.typeCode, TYPE_CODE: type.typeCode,
ORG_CODE: orgCode, ORG_CODE: orgCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
}, },
}); });
const correspondence = queryRunner.manager.create(Correspondence, { const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber, correspondenceNumber: docNumber.number,
correspondenceTypeId: createDto.typeId, correspondenceTypeId: createDto.typeId,
disciplineId: createDto.disciplineId, // บันทึก Discipline ลง DB disciplineId: createDto.disciplineId,
projectId: createDto.projectId, projectId: createDto.projectId,
originatorId: userOrgId, originatorId: userOrgId,
isInternal: createDto.isInternal || false, isInternal: createDto.isInternal || false,
@@ -156,16 +172,32 @@ export class CorrespondenceService {
revisionLabel: 'A', revisionLabel: 'A',
isCurrent: true, isCurrent: true,
statusId: statusDraft.id, statusId: statusDraft.id,
title: createDto.title, subject: createDto.subject,
body: createDto.body,
remarks: createDto.remarks,
dueDate: createDto.dueDate ? new Date(createDto.dueDate) : undefined,
description: createDto.description, description: createDto.description,
details: createDto.details, details: createDto.details,
createdBy: user.user_id, createdBy: user.user_id,
schemaVersion: 1,
}); });
await queryRunner.manager.save(revision); await queryRunner.manager.save(revision);
// Save Recipients
if (createDto.recipients && createDto.recipients.length > 0) {
const recipients = createDto.recipients.map((r) =>
queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorr.id,
recipientOrganizationId: r.organizationId,
recipientType: r.type,
})
);
await queryRunner.manager.save(recipients);
}
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
// [NEW V1.5.1] Start Workflow Instance (After Commit) // Start Workflow Instance (non-blocking)
try { try {
const workflowCode = `CORRESPONDENCE_${type.typeCode}`; const workflowCode = `CORRESPONDENCE_${type.typeCode}`;
await this.workflowEngine.createInstance( await this.workflowEngine.createInstance(
@@ -181,16 +213,15 @@ export class CorrespondenceService {
); );
} catch (error) { } catch (error) {
this.logger.warn( this.logger.warn(
`Workflow not started for ${docNumber} (Code: CORRESPONDENCE_${type.typeCode}): ${(error as Error).message}` `Workflow not started for ${docNumber.number} (Code: CORRESPONDENCE_${type.typeCode}): ${(error as Error).message}`
); );
// Non-blocking: Document is created, but workflow might not be active.
} }
this.searchService.indexDocument({ this.searchService.indexDocument({
id: savedCorr.id, id: savedCorr.id,
type: 'correspondence', type: 'correspondence',
docNumber: docNumber, docNumber: docNumber.number,
title: createDto.title, title: createDto.subject,
description: createDto.description, description: createDto.description,
status: 'DRAFT', status: 'DRAFT',
projectId: createDto.projectId, projectId: createDto.projectId,
@@ -212,17 +243,35 @@ export class CorrespondenceService {
} }
} }
// ... (method อื่นๆ คงเดิม)
async findAll(searchDto: SearchCorrespondenceDto = {}) { async findAll(searchDto: SearchCorrespondenceDto = {}) {
const { search, typeId, projectId, statusId } = searchDto; const {
search,
typeId,
projectId,
statusId,
page = 1,
limit = 10,
} = searchDto;
const skip = (page - 1) * limit;
const query = this.correspondenceRepo // Change: Query from Revision Repo
.createQueryBuilder('corr') const query = this.revisionRepo
.leftJoinAndSelect('corr.revisions', 'rev') .createQueryBuilder('rev')
.leftJoinAndSelect('rev.correspondence', 'corr')
.leftJoinAndSelect('corr.type', 'type') .leftJoinAndSelect('corr.type', 'type')
.leftJoinAndSelect('corr.project', 'project') .leftJoinAndSelect('corr.project', 'project')
.leftJoinAndSelect('corr.originator', 'org') .leftJoinAndSelect('corr.originator', 'org')
.where('rev.isCurrent = :isCurrent', { isCurrent: true }); .leftJoinAndSelect('rev.status', 'status');
// Filter by Revision Status
const revStatus = searchDto.revisionStatus || 'CURRENT';
if (revStatus === 'CURRENT') {
query.where('rev.isCurrent = :isCurrent', { isCurrent: true });
} else if (revStatus === 'OLD') {
query.where('rev.isCurrent = :isCurrent', { isCurrent: false });
}
// If 'ALL', no filter needed on isCurrent
if (projectId) { if (projectId) {
query.andWhere('corr.projectId = :projectId', { projectId }); query.andWhere('corr.projectId = :projectId', { projectId });
@@ -238,14 +287,25 @@ export class CorrespondenceService {
if (search) { if (search) {
query.andWhere( query.andWhere(
'(corr.correspondenceNumber LIKE :search OR rev.title LIKE :search)', '(corr.correspondenceNumber LIKE :search OR rev.subject LIKE :search)',
{ search: `%${search}%` } { search: `%${search}%` }
); );
} }
query.orderBy('corr.createdAt', 'DESC'); // Default Sort: Latest Created
query.orderBy('rev.createdAt', 'DESC').skip(skip).take(limit);
return query.getMany(); const [items, total] = await query.getManyAndCount();
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
} }
async findOne(id: number) { async findOne(id: number) {
@@ -257,6 +317,8 @@ export class CorrespondenceService {
'type', 'type',
'project', 'project',
'originator', 'originator',
'recipients',
'recipients.recipientOrganization', // [v1.5.1] Fixed relation name
], ],
}); });
@@ -266,182 +328,6 @@ export class CorrespondenceService {
return correspondence; return correspondence;
} }
async submit(correspondenceId: number, templateId: number, user: User) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId },
relations: ['revisions'],
});
if (!correspondence) {
throw new NotFoundException('Correspondence not found');
}
const currentRevision = correspondence.revisions?.find((r) => r.isCurrent);
if (!currentRevision) {
throw new NotFoundException('Current revision not found');
}
const template = await this.templateRepo.findOne({
where: { id: templateId },
relations: ['steps'],
order: { steps: { sequence: 'ASC' } },
});
if (!template || !template.steps?.length) {
throw new BadRequestException(
'Invalid routing template or no steps defined'
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const firstStep = template.steps[0];
const routing = queryRunner.manager.create(CorrespondenceRouting, {
correspondenceId: currentRevision.id,
templateId: template.id,
sequence: 1,
fromOrganizationId: user.primaryOrganizationId,
toOrganizationId: firstStep.toOrganizationId,
stepPurpose: firstStep.stepPurpose,
status: 'SENT',
dueDate: new Date(
Date.now() + (firstStep.expectedDays || 7) * 24 * 60 * 60 * 1000
),
processedByUserId: user.user_id,
processedAt: new Date(),
});
await queryRunner.manager.save(routing);
await queryRunner.commitTransaction();
return routing;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async processAction(
correspondenceId: number,
dto: WorkflowActionDto,
user: User
) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId },
relations: ['revisions'],
});
if (!correspondence)
throw new NotFoundException('Correspondence not found');
const currentRevision = correspondence.revisions?.find((r) => r.isCurrent);
if (!currentRevision)
throw new NotFoundException('Current revision not found');
const currentRouting = await this.routingRepo.findOne({
where: {
correspondenceId: currentRevision.id,
status: 'SENT',
},
order: { sequence: 'DESC' },
relations: ['toOrganization'],
});
if (!currentRouting) {
throw new BadRequestException(
'No active workflow step found for this document'
);
}
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
throw new BadRequestException(
'You are not authorized to process this step'
);
}
if (!currentRouting.templateId) {
throw new InternalServerErrorException(
'Routing record missing templateId'
);
}
const template = await this.templateRepo.findOne({
where: { id: currentRouting.templateId },
relations: ['steps'],
});
if (!template || !template.steps) {
throw new InternalServerErrorException('Template definition not found');
}
const totalSteps = template.steps.length;
const currentSeq = currentRouting.sequence;
const result = this.workflowEngine.processAction(
currentSeq,
totalSteps,
dto.action,
dto.returnToSequence
);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
currentRouting.status =
dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
currentRouting.processedByUserId = user.user_id;
currentRouting.processedAt = new Date();
currentRouting.comments = dto.comments;
await queryRunner.manager.save(currentRouting);
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
const nextStepConfig = template.steps.find(
(s) => s.sequence === result.nextStepSequence
);
if (!nextStepConfig) {
this.logger.warn(
`Next step ${result.nextStepSequence} not found in template`
);
} else {
const nextRouting = queryRunner.manager.create(
CorrespondenceRouting,
{
correspondenceId: currentRevision.id,
templateId: template.id,
sequence: result.nextStepSequence,
fromOrganizationId: user.primaryOrganizationId,
toOrganizationId: nextStepConfig.toOrganizationId,
stepPurpose: nextStepConfig.stepPurpose,
status: 'SENT',
dueDate: new Date(
Date.now() +
(nextStepConfig.expectedDays || 7) * 24 * 60 * 60 * 1000
),
}
);
await queryRunner.manager.save(nextRouting);
}
}
await queryRunner.commitTransaction();
return { message: 'Action processed successfully', result };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async addReference(id: number, dto: AddReferenceDto) { async addReference(id: number, dto: AddReferenceDto) {
const source = await this.correspondenceRepo.findOne({ where: { id } }); const source = await this.correspondenceRepo.findOne({ where: { id } });
const target = await this.correspondenceRepo.findOne({ const target = await this.correspondenceRepo.findOne({
@@ -499,4 +385,236 @@ export class CorrespondenceService {
return { outgoing, incoming }; return { outgoing, incoming };
} }
async update(id: number, updateDto: UpdateCorrespondenceDto, user: User) {
// 1. Find Current Revision
const revision = await this.revisionRepo.findOne({
where: {
correspondenceId: id,
isCurrent: true,
},
relations: ['correspondence'],
});
if (!revision) {
throw new NotFoundException(
`Current revision for correspondence ${id} not found`
);
}
// 2. Check Permission
if (revision.statusId) {
const status = await this.statusRepo.findOne({
where: { id: revision.statusId },
});
if (status && status.statusCode !== 'DRAFT') {
throw new BadRequestException('Only DRAFT documents can be updated');
}
}
// 3. Update Correspondence Entity if needed
const correspondenceUpdate: DeepPartial<Correspondence> = {};
if (updateDto.disciplineId)
correspondenceUpdate.disciplineId = updateDto.disciplineId;
if (updateDto.projectId)
correspondenceUpdate.projectId = updateDto.projectId;
if (updateDto.originatorId)
correspondenceUpdate.originatorId = updateDto.originatorId;
if (Object.keys(correspondenceUpdate).length > 0) {
await this.correspondenceRepo.update(id, correspondenceUpdate);
}
// 4. Update Revision Entity
const revisionUpdate: DeepPartial<CorrespondenceRevision> = {};
if (updateDto.subject) revisionUpdate.subject = updateDto.subject;
if (updateDto.body) revisionUpdate.body = updateDto.body;
if (updateDto.remarks) revisionUpdate.remarks = updateDto.remarks;
// Format Date correctly if string
if (updateDto.dueDate) revisionUpdate.dueDate = new Date(updateDto.dueDate);
if (updateDto.description)
revisionUpdate.description = updateDto.description;
if (updateDto.details) revisionUpdate.details = updateDto.details;
if (Object.keys(revisionUpdate).length > 0) {
await this.revisionRepo.update(revision.id, revisionUpdate);
}
// 5. Update Recipients if provided
if (updateDto.recipients) {
const recipientRepo = this.dataSource.getRepository(
CorrespondenceRecipient
);
await recipientRepo.delete({ correspondenceId: id });
const newRecipients = updateDto.recipients.map((r) =>
recipientRepo.create({
correspondenceId: id,
recipientOrganizationId: r.organizationId,
recipientType: r.type,
})
);
await recipientRepo.save(newRecipients);
}
// 6. Regenerate Document Number if structural fields changed (Recipient, Discipline, Type, Project)
// AND it is a DRAFT.
// Fetch fresh data for context and comparison
const currentCorr = await this.correspondenceRepo.findOne({
where: { id },
relations: ['type', 'recipients', 'recipients.recipientOrganization'],
});
if (currentCorr) {
const currentToRecipient = currentCorr.recipients?.find(
(r) => r.recipientType === 'TO'
);
const currentRecipientId = currentToRecipient?.recipientOrganizationId;
// Check for ACTUAL value changes
const isProjectChanged =
updateDto.projectId !== undefined &&
updateDto.projectId !== currentCorr.projectId;
const isOriginatorChanged =
updateDto.originatorId !== undefined &&
updateDto.originatorId !== currentCorr.originatorId;
const isDisciplineChanged =
updateDto.disciplineId !== undefined &&
updateDto.disciplineId !== currentCorr.disciplineId;
const isTypeChanged =
updateDto.typeId !== undefined &&
updateDto.typeId !== currentCorr.correspondenceTypeId;
let isRecipientChanged = false;
let newRecipientId: number | undefined;
if (updateDto.recipients) {
// Safe check for 'type' or 'recipientType' (mismatch safeguard)
const newToRecipient = updateDto.recipients.find(
(r: any) => r.type === 'TO' || r.recipientType === 'TO'
);
newRecipientId = newToRecipient?.organizationId;
if (newRecipientId !== currentRecipientId) {
isRecipientChanged = true;
}
}
if (
isProjectChanged ||
isDisciplineChanged ||
isTypeChanged ||
isRecipientChanged ||
isOriginatorChanged
) {
const targetRecipientId = isRecipientChanged
? newRecipientId
: currentRecipientId;
// Resolve Recipient Code for the NEW context
let recipientCode = '';
if (targetRecipientId) {
const recOrg = await this.orgRepo.findOne({
where: { id: targetRecipientId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
const orgCode = 'ORG'; // Placeholder - should be fetched from Originator if needed in future
// Prepare Contexts
const oldCtx = {
projectId: currentCorr.projectId,
originatorOrganizationId: currentCorr.originatorId ?? 0,
typeId: currentCorr.correspondenceTypeId,
disciplineId: currentCorr.disciplineId,
recipientOrganizationId: currentRecipientId,
year: new Date().getFullYear(),
};
const newCtx = {
projectId: updateDto.projectId ?? currentCorr.projectId,
originatorOrganizationId:
updateDto.originatorId ?? currentCorr.originatorId ?? 0,
typeId: updateDto.typeId ?? currentCorr.correspondenceTypeId,
disciplineId: updateDto.disciplineId ?? currentCorr.disciplineId,
recipientOrganizationId: targetRecipientId,
year: new Date().getFullYear(),
userId: user.user_id, // Pass User ID for Audit
customTokens: {
TYPE_CODE: currentCorr.type?.typeCode || '',
ORG_CODE: orgCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
};
// If Type Changed, need NEW Type Code
if (isTypeChanged) {
const newType = await this.typeRepo.findOne({
where: { id: newCtx.typeId },
});
if (newType) newCtx.customTokens.TYPE_CODE = newType.typeCode;
}
const newDocNumber = await this.numberingService.updateNumberForDraft(
currentCorr.correspondenceNumber,
oldCtx,
newCtx
);
await this.correspondenceRepo.update(id, {
correspondenceNumber: newDocNumber,
});
}
}
return this.findOne(id);
}
async previewDocumentNumber(createDto: CreateCorrespondenceDto, user: User) {
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
if (!type) throw new NotFoundException('Document Type not found');
let userOrgId = user.primaryOrganizationId;
if (!userOrgId) {
const fullUser = await this.userService.findOne(user.user_id);
if (fullUser) userOrgId = fullUser.primaryOrganizationId;
}
if (createDto.originatorId && createDto.originatorId !== userOrgId) {
// Allow impersonation for preview
userOrgId = createDto.originatorId;
}
// Extract recipient from recipients array
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.orgRepo.findOne({
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
return this.numberingService.previewNumber({
projectId: createDto.projectId,
originatorOrganizationId: userOrgId!,
typeId: createDto.typeId,
disciplineId: createDto.disciplineId,
subTypeId: createDto.subTypeId,
recipientOrganizationId,
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
});
}
} }

View File

@@ -1,6 +1,11 @@
import { IsInt, IsNotEmpty } from 'class-validator'; import { IsInt, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AddReferenceDto { export class AddReferenceDto {
@ApiProperty({
description: 'Target Correspondence ID to reference',
example: 20,
})
@IsInt() @IsInt()
@IsNotEmpty() @IsNotEmpty()
targetId!: number; targetId!: number;

View File

@@ -1,4 +1,3 @@
// File: src/modules/correspondence/dto/create-correspondence.dto.ts
import { import {
IsInt, IsInt,
IsString, IsString,
@@ -6,43 +5,99 @@ import {
IsOptional, IsOptional,
IsBoolean, IsBoolean,
IsObject, IsObject,
IsDateString,
IsArray,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCorrespondenceDto { export class CreateCorrespondenceDto {
@ApiProperty({ description: 'Project ID', example: 1 })
@IsInt() @IsInt()
@IsNotEmpty() @IsNotEmpty()
projectId!: number; projectId!: number;
@ApiProperty({ description: 'Document Type ID', example: 1 })
@IsInt() @IsInt()
@IsNotEmpty() @IsNotEmpty()
typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER) typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER)
@ApiPropertyOptional({ description: 'Discipline ID', example: 2 })
@IsInt() @IsInt()
@IsOptional() @IsOptional()
disciplineId?: number; // [Req 6B] สาขางาน (เช่น GEN, STR) disciplineId?: number; // [Req 6B] สาขางาน (เช่น GEN, STR)
@ApiPropertyOptional({ description: 'Sub Type ID', example: 3 })
@IsInt() @IsInt()
@IsOptional() @IsOptional()
subTypeId?: number; // [Req 6B] ประเภทย่อย (เช่น MAT, SHP สำหรับ Transmittal/RFA) subTypeId?: number; // [Req 6B] ประเภทย่อย (เช่น MAT, SHP สำหรับ Transmittal/RFA)
@ApiProperty({
description: 'Correspondence Subject',
example: 'Monthly Progress Report',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
title!: string; subject!: string;
@ApiPropertyOptional({
description: 'Body/Content',
example: '<p>...</p>',
})
@IsString()
@IsOptional()
body?: string;
@ApiPropertyOptional({
description: 'Remarks',
example: 'Note...',
})
@IsString()
@IsOptional()
remarks?: string;
@ApiPropertyOptional({
description: 'Due Date',
example: '2025-12-06T00:00:00Z',
})
@IsDateString()
@IsOptional()
dueDate?: string;
@ApiPropertyOptional({
description: 'Correspondence Description',
example: 'Detailed report...',
})
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiPropertyOptional({
description: 'Additional details (JSON)',
example: { key: 'value' },
})
@IsObject() @IsObject()
@IsOptional() @IsOptional()
details?: Record<string, any>; // ข้อมูล JSON (เช่น RFI question) details?: Record<string, any>; // ข้อมูล JSON (เช่น RFI question)
@ApiPropertyOptional({ description: 'Is internal document?', default: false })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isInternal?: boolean; isInternal?: boolean;
// ✅ เพิ่ม Field สำหรับ Impersonation (เลือกองค์กรผู้ส่ง) // ✅ เพิ่ม Field สำหรับ Impersonation (เลือกองค์กรผู้ส่ง)
@ApiPropertyOptional({
description: 'Originator Organization ID (for impersonation)',
example: 1,
})
@IsInt() @IsInt()
@IsOptional() @IsOptional()
originatorId?: number; originatorId?: number;
@ApiPropertyOptional({
description: 'Recipients',
example: [{ organizationId: 1, type: 'TO' }],
})
@IsArray()
@IsOptional()
recipients?: { organizationId: number; type: 'TO' | 'CC' }[];
} }

View File

@@ -1,24 +1,52 @@
import { IsOptional, IsString, IsInt } from 'class-validator'; import { IsOptional, IsString, IsInt } from 'class-validator';
import { Type } from 'class-transformer'; // <--- ✅ Import จาก class-transformer import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchCorrespondenceDto { export class SearchCorrespondenceDto {
@ApiPropertyOptional({
description: 'Search term (Title or Document Number)',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
search?: string; // ค้นหาจาก Title หรือ Number search?: string;
@ApiPropertyOptional({ description: 'Filter by Document Type ID' })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
typeId?: number; typeId?: number;
@ApiPropertyOptional({ description: 'Filter by Project ID' })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
projectId?: number; projectId?: number;
// status อาจจะซับซ้อนหน่อยเพราะอยู่ที่ Revision แต่ใส่ไว้ก่อน @ApiPropertyOptional({ description: 'Filter by Status ID' })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
statusId?: number; statusId?: number;
@ApiPropertyOptional({
description: 'Revision Filter: CURRENT (default), ALL, OLD',
})
@IsOptional()
@IsString()
revisionStatus?: 'CURRENT' | 'ALL' | 'OLD';
@ApiPropertyOptional({ description: 'Page number (default 1)', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
page?: number;
@ApiPropertyOptional({
description: 'Items per page (default 10)',
default: 10,
})
@IsOptional()
@Type(() => Number)
@IsInt()
limit?: number;
} }

View File

@@ -1,7 +1,16 @@
import { IsInt, IsNotEmpty } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for submitting correspondence to workflow
* Uses Unified Workflow Engine - no templateId required
*/
export class SubmitCorrespondenceDto { export class SubmitCorrespondenceDto {
@IsInt() @ApiPropertyOptional({
@IsNotEmpty() description: 'Optional note for the submission',
templateId!: number; example: 'Submitting for review',
})
@IsString()
@IsOptional()
note?: string;
} }

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCorrespondenceDto } from './create-correspondence.dto';
export class UpdateCorrespondenceDto extends PartialType(
CreateCorrespondenceDto
) {}

View File

@@ -1,15 +1,61 @@
import { IsEnum, IsString, IsOptional, IsInt } from 'class-validator'; import { IsEnum, IsString, IsOptional, IsUUID, IsInt } from 'class-validator';
import { WorkflowAction } from '../../workflow-engine/interfaces/workflow.interface.js'; import { WorkflowAction } from '../../workflow-engine/interfaces/workflow.interface';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for processing workflow actions
*
* Supports both:
* - New Unified Workflow Engine (uses instanceId)
* - Legacy RFA workflow (uses returnToSequence)
*/
export class WorkflowActionDto { export class WorkflowActionDto {
@IsEnum(WorkflowAction) @ApiPropertyOptional({
action!: WorkflowAction; // APPROVE, REJECT, RETURN, ACKNOWLEDGE description: 'Workflow Instance ID (UUID) - for Unified Workflow Engine',
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
})
@IsUUID()
@IsOptional()
instanceId?: string;
@ApiProperty({
description: 'Workflow Action',
enum: ['APPROVE', 'REJECT', 'RETURN', 'CANCEL', 'ACKNOWLEDGE'],
})
@IsEnum(WorkflowAction)
action!: WorkflowAction;
@ApiPropertyOptional({
description: 'Review comments',
example: 'Approved with note...',
})
@IsString()
@IsOptional()
comment?: string;
/**
* @deprecated Use 'comment' instead
*/
@ApiPropertyOptional({
description: 'Review comments (deprecated, use comment)',
example: 'Approved with note...',
})
@IsString() @IsString()
@IsOptional() @IsOptional()
comments?: string; comments?: string;
@ApiPropertyOptional({
description: 'Sequence to return to (only for RETURN action in legacy RFA)',
example: 1,
})
@IsInt() @IsInt()
@IsOptional() @IsOptional()
returnToSequence?: number; // ใช้กรณี action = RETURN returnToSequence?: number;
@ApiPropertyOptional({
description: 'Additional payload data',
example: { priority: 'HIGH' },
})
@IsOptional()
payload?: Record<string, unknown>;
} }

View File

@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Correspondence } from './correspondence.entity';
import { Organization } from '../../organization/entities/organization.entity';
@Entity('correspondence_recipients')
export class CorrespondenceRecipient {
@PrimaryColumn({ name: 'correspondence_id' })
correspondenceId!: number;
@PrimaryColumn({ name: 'recipient_organization_id' })
recipientOrganizationId!: number;
@PrimaryColumn({ name: 'recipient_type', type: 'enum', enum: ['TO', 'CC'] })
recipientType!: 'TO' | 'CC';
// Relations
@ManyToOne(() => Correspondence, (corr) => corr.recipients, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'correspondence_id' })
correspondence!: Correspondence;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'recipient_organization_id' })
recipientOrganization!: Organization;
}

View File

@@ -1,5 +1,5 @@
import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Correspondence } from './correspondence.entity.js'; import { Correspondence } from './correspondence.entity';
@Entity('correspondence_references') @Entity('correspondence_references')
export class CorrespondenceReference { export class CorrespondenceReference {

View File

@@ -8,9 +8,9 @@ import {
CreateDateColumn, CreateDateColumn,
Index, Index,
} from 'typeorm'; } from 'typeorm';
import { Correspondence } from './correspondence.entity.js'; import { Correspondence } from './correspondence.entity';
import { CorrespondenceStatus } from './correspondence-status.entity.js'; import { CorrespondenceStatus } from './correspondence-status.entity';
import { User } from '../../user/entities/user.entity.js'; import { User } from '../../user/entities/user.entity';
@Entity('correspondence_revisions') @Entity('correspondence_revisions')
// ✅ เพิ่ม Index สำหรับ Virtual Columns เพื่อให้ Search เร็วขึ้น // ✅ เพิ่ม Index สำหรับ Virtual Columns เพื่อให้ Search เร็วขึ้น
@@ -35,15 +35,24 @@ export class CorrespondenceRevision {
@Column({ name: 'correspondence_status_id' }) @Column({ name: 'correspondence_status_id' })
statusId!: number; statusId!: number;
@Column({ length: 255 }) @Column({ length: 500 })
title!: string; subject!: string;
@Column({ name: 'description', type: 'text', nullable: true }) @Column({ name: 'description', type: 'text', nullable: true })
description?: string; description?: string;
@Column({ type: 'text', nullable: true })
body?: string;
@Column({ type: 'text', nullable: true })
remarks?: string;
@Column({ type: 'json', nullable: true }) @Column({ type: 'json', nullable: true })
details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type
@Column({ name: 'schema_version', default: 1 })
schemaVersion!: number;
// ✅ [New] Virtual Column: ดึง Project ID จาก JSON details // ✅ [New] Virtual Column: ดึง Project ID จาก JSON details
@Column({ @Column({
name: 'v_ref_project_id', name: 'v_ref_project_id',

View File

@@ -7,10 +7,10 @@ import {
JoinColumn, JoinColumn,
CreateDateColumn, CreateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { CorrespondenceRevision } from './correspondence-revision.entity.js'; import { CorrespondenceRevision } from './correspondence-revision.entity';
import { Organization } from '../../project/entities/organization.entity.js'; import { Organization } from '../../organization/entities/organization.entity';
import { User } from '../../user/entities/user.entity.js'; import { User } from '../../user/entities/user.entity';
import { RoutingTemplate } from './routing-template.entity.js'; import { RoutingTemplate } from './routing-template.entity';
@Entity('correspondence_routings') @Entity('correspondence_routings')
export class CorrespondenceRouting { export class CorrespondenceRouting {

View File

@@ -7,7 +7,7 @@ import {
JoinColumn, JoinColumn,
CreateDateColumn, CreateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Contract } from '../../project/entities/contract.entity'; // ปรับ path ตามจริง import { Contract } from '../../contract/entities/contract.entity'; // ปรับ path ตามจริง
import { CorrespondenceType } from './correspondence-type.entity'; // ปรับ path ตามจริง import { CorrespondenceType } from './correspondence-type.entity'; // ปรับ path ตามจริง
@Entity('correspondence_sub_types') @Entity('correspondence_sub_types')

View File

@@ -8,11 +8,13 @@ import {
DeleteDateColumn, DeleteDateColumn,
CreateDateColumn, CreateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Project } from '../../project/entities/project.entity.js'; import { Project } from '../../project/entities/project.entity';
import { Organization } from '../../project/entities/organization.entity.js'; import { Organization } from '../../organization/entities/organization.entity';
import { CorrespondenceType } from './correspondence-type.entity.js'; import { CorrespondenceType } from './correspondence-type.entity';
import { User } from '../../user/entities/user.entity.js'; import { User } from '../../user/entities/user.entity';
import { CorrespondenceRevision } from './correspondence-revision.entity.js'; // เดี๋ยวสร้าง import { CorrespondenceRecipient } from './correspondence-recipient.entity';
import { CorrespondenceRevision } from './correspondence-revision.entity';
import { Discipline } from '../../master/entities/discipline.entity';
@Entity('correspondences') @Entity('correspondences')
export class Correspondence { export class Correspondence {
@@ -68,9 +70,9 @@ export class Correspondence {
creator?: User; creator?: User;
// [New V1.5.1] // [New V1.5.1]
@ManyToOne('Discipline') @ManyToOne(() => Discipline)
@JoinColumn({ name: 'discipline_id' }) @JoinColumn({ name: 'discipline_id' })
discipline?: any; // Use 'any' or import Discipline entity if available to avoid circular dependency issues if not careful, but better to import. discipline?: Discipline;
// One Correspondence has Many Revisions // One Correspondence has Many Revisions
@OneToMany( @OneToMany(
@@ -78,4 +80,11 @@ export class Correspondence {
(revision) => revision.correspondence (revision) => revision.correspondence
) )
revisions?: CorrespondenceRevision[]; revisions?: CorrespondenceRevision[];
@OneToMany(
() => CorrespondenceRecipient,
(recipient) => recipient.correspondence,
{ cascade: true }
)
recipients?: CorrespondenceRecipient[];
} }

View File

@@ -1,15 +1,12 @@
// File: src/modules/correspondence/entities/routing-template-step.entity.ts import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { RoutingTemplate } from './routing-template.entity.js';
import { Organization } from '../../project/entities/organization.entity.js';
import { Role } from '../../user/entities/role.entity.js';
/**
* @deprecated This entity is deprecated and will be removed in future versions.
* Use WorkflowDefinition from the Unified Workflow Engine instead.
*
* This entity is kept for backward compatibility and historical data.
* Relations have been removed to prevent TypeORM errors.
*/
@Entity('correspondence_routing_template_steps') @Entity('correspondence_routing_template_steps')
export class RoutingTemplateStep { export class RoutingTemplateStep {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@@ -24,27 +21,12 @@ export class RoutingTemplateStep {
@Column({ name: 'to_organization_id' }) @Column({ name: 'to_organization_id' })
toOrganizationId!: number; toOrganizationId!: number;
@Column({ name: 'role_id', nullable: true }) @Column({ name: 'step_purpose', length: 50, default: 'FOR_REVIEW' })
roleId?: number;
@Column({ name: 'step_purpose', default: 'FOR_REVIEW' })
stepPurpose!: string; stepPurpose!: string;
@Column({ name: 'expected_days', nullable: true }) @Column({ name: 'expected_days', default: 7 })
expectedDays?: number; expectedDays!: number;
// Relations // @deprecated - Relation removed, use WorkflowDefinition instead
@ManyToOne(() => RoutingTemplate, (template) => template.steps, { // template?: RoutingTemplate;
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'template_id' })
template?: RoutingTemplate;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'to_organization_id' })
toOrganization?: Organization;
@ManyToOne(() => Role)
@JoinColumn({ name: 'role_id' })
role?: Role;
} }

View File

@@ -1,7 +1,12 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity.js'; // ถ้าไม่ได้ใช้ BaseEntity ก็ลบออกแล้วใส่ createdAt เอง
import { RoutingTemplateStep } from './routing-template-step.entity.js'; // เดี๋ยวสร้าง
/**
* @deprecated This entity is deprecated and will be removed in future versions.
* Use WorkflowDefinition from the Unified Workflow Engine instead.
*
* This entity is kept for backward compatibility and historical data.
* The relation to RoutingTemplateStep has been removed to prevent TypeORM errors.
*/
@Entity('correspondence_routing_templates') @Entity('correspondence_routing_templates')
export class RoutingTemplate { export class RoutingTemplate {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@@ -14,14 +19,14 @@ export class RoutingTemplate {
description?: string; description?: string;
@Column({ name: 'project_id', nullable: true }) @Column({ name: 'project_id', nullable: true })
projectId?: number; // NULL = แม่แบบทั่วไป projectId?: number;
@Column({ name: 'is_active', default: true }) @Column({ name: 'is_active', default: true })
isActive!: boolean; isActive!: boolean;
@Column({ type: 'json', nullable: true, name: 'workflow_config' }) @Column({ type: 'json', nullable: true, name: 'workflow_config' })
workflowConfig?: any; workflowConfig?: Record<string, unknown>;
@OneToMany(() => RoutingTemplateStep, (step) => step.template) // @deprecated - Relation removed, use WorkflowDefinition instead
steps?: RoutingTemplateStep[]; // steps?: RoutingTemplateStep[];
} }

View File

@@ -1,36 +0,0 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('correspondences')
export class Correspondence {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'document_number', length: 50, unique: true })
documentNumber!: string;
@Column({ length: 255 })
subject!: string;
@Column({ type: 'text', nullable: true })
body!: string;
@Column({ length: 50 })
type!: string;
@Column({ length: 50, default: 'Draft' })
status!: string;
@Column({ name: 'created_by_id' })
createdById!: number;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by_id' })
createdBy!: User;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,51 @@
// File: src/modules/dashboard/dashboard.controller.ts
// บันทึกการแก้ไข: สร้างใหม่สำหรับ Dashboard API Endpoints
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
// Guards & Decorators
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
// Service
import { DashboardService } from './dashboard.service';
// DTOs
import { GetActivityDto, GetPendingDto } from './dto';
@ApiTags('Dashboard')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
/**
* ดึงสถิติ Dashboard
*/
@Get('stats')
@ApiOperation({ summary: 'Get dashboard statistics' })
async getStats(@CurrentUser() user: User) {
return this.dashboardService.getStats(user.user_id);
}
/**
* ดึง Recent Activity
*/
@Get('activity')
@ApiOperation({ summary: 'Get recent activity' })
async getActivity(@CurrentUser() user: User, @Query() query: GetActivityDto) {
return this.dashboardService.getActivity(user.user_id, query);
}
/**
* ดึง Pending Tasks
*/
@Get('pending')
@ApiOperation({ summary: 'Get pending tasks for current user' })
async getPending(@CurrentUser() user: User, @Query() query: GetPendingDto) {
return this.dashboardService.getPending(user.user_id, query);
}
}

View File

@@ -0,0 +1,24 @@
// File: src/modules/dashboard/dashboard.module.ts
// บันทึกการแก้ไข: สร้างใหม่สำหรับ Dashboard Module
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// Entities
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { AuditLog } from '../../common/entities/audit-log.entity';
import { WorkflowInstance } from '../workflow-engine/entities/workflow-instance.entity';
// Controller & Service
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
imports: [
TypeOrmModule.forFeature([Correspondence, AuditLog, WorkflowInstance]),
],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,214 @@
// File: src/modules/dashboard/dashboard.service.ts
// บันทึกการแก้ไข: สร้างใหม่สำหรับ Dashboard Business Logic
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
// Entities
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { AuditLog } from '../../common/entities/audit-log.entity';
import {
WorkflowInstance,
WorkflowStatus,
} from '../workflow-engine/entities/workflow-instance.entity';
// DTOs
import {
DashboardStatsDto,
GetActivityDto,
ActivityItemDto,
GetPendingDto,
PendingTaskItemDto,
} from './dto';
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@InjectRepository(AuditLog)
private auditLogRepo: Repository<AuditLog>,
@InjectRepository(WorkflowInstance)
private workflowInstanceRepo: Repository<WorkflowInstance>,
private dataSource: DataSource
) {}
/**
* ดึงสถิติ Dashboard
* @param userId - ID ของ User ที่ Login
*/
async getStats(userId: number): Promise<DashboardStatsDto> {
this.logger.debug(`Getting dashboard stats for user ${userId}`);
// นับจำนวนเอกสารทั้งหมด
const totalDocuments = await this.correspondenceRepo.count();
// นับจำนวนเอกสารเดือนนี้
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const documentsThisMonth = await this.correspondenceRepo
.createQueryBuilder('c')
.where('c.createdAt >= :startOfMonth', { startOfMonth })
.getCount();
// นับงานที่รอ Approve (Workflow Active)
const pendingApprovals = await this.workflowInstanceRepo.count({
where: { status: WorkflowStatus.ACTIVE },
});
// นับ RFA ทั้งหมด (correspondence_type_id = RFA type)
// ใช้ Raw Query เพราะต้อง JOIN กับ correspondence_types
const rfaCountResult = await this.dataSource.query(`
SELECT COUNT(*) as count
FROM correspondences c
JOIN correspondence_types ct ON c.correspondence_type_id = ct.id
WHERE ct.type_code = 'RFA'
`);
const totalRfas = parseInt(rfaCountResult[0]?.count || '0', 10);
// นับ Circulation ทั้งหมด
const circulationsCountResult = await this.dataSource.query(`
SELECT COUNT(*) as count FROM circulations
`);
const totalCirculations = parseInt(
circulationsCountResult[0]?.count || '0',
10
);
// นับเอกสารที่อนุมัติแล้ว (APPROVED)
// NOTE: อาจจะต้องปรับ logic ตาม Business ว่า "อนุมัติ" หมายถึงอะไร
// เบื้องต้นนับจาก CorrespondenceStatus ที่เป็น 'APPROVED' หรือ 'CODE 1'
// หรือนับจาก Workflow ที่ Completed และ Action เป็น APPROVE
// เพื่อความง่ายในเบื้องต้น นับจาก CorrespondenceRevision ที่มี status 'APPROVED' (ถ้ามี)
// หรือนับจาก RFA ที่มี Approve Code
// สำหรับ LCBP3 นับ RFA ที่ approveCodeId ไม่ใช่ null (หรือ check status code = APR/FAP)
// และ Correspondence ทั่วไปที่มีสถานะ Completed
// เพื่อความรวดเร็ว ใช้วิธีนับ Revision ที่ isCurrent = 1 และ statusCode = 'APR' (Approved)
// Check status code 'APR' exists
const aprStatusCount = await this.dataSource.query(`
SELECT COUNT(r.id) as count
FROM correspondence_revisions r
JOIN correspondence_status s ON r.correspondence_status_id = s.id
WHERE r.is_current = 1 AND s.status_code IN ('APR', 'CMP')
`);
const approved = parseInt(aprStatusCount[0]?.count || '0', 10);
return {
totalDocuments,
documentsThisMonth,
pendingApprovals,
approved,
totalRfas,
totalCirculations,
};
}
/**
* ดึง Activity ล่าสุด
* @param userId - ID ของ User ที่ Login
* @param dto - Query params
*/
async getActivity(
userId: number,
dto: GetActivityDto
): Promise<ActivityItemDto[]> {
const { limit = 10 } = dto;
this.logger.debug(`Getting recent activity for user ${userId}`);
// ดึง Recent Audit Logs
const logs = await this.auditLogRepo
.createQueryBuilder('log')
.leftJoin('log.user', 'user')
.select([
'log.action',
'log.entityType',
'log.entityId',
'log.detailsJson',
'log.createdAt',
'user.username',
])
.orderBy('log.createdAt', 'DESC')
.limit(limit)
.getMany();
return logs.map((log) => ({
action: log.action,
entityType: log.entityType,
entityId: log.entityId,
details: log.detailsJson,
createdAt: log.createdAt,
username: log.user?.username,
}));
}
/**
* ดึง Pending Tasks ของ User
* ใช้ v_user_tasks view จาก Database
* @param userId - ID ของ User ที่ Login
* @param dto - Query params
*/
async getPending(
userId: number,
dto: GetPendingDto
): Promise<{
data: PendingTaskItemDto[];
meta: { total: number; page: number; limit: number };
}> {
const { page = 1, limit = 10 } = dto;
const offset = (page - 1) * limit;
this.logger.debug(`Getting pending tasks for user ${userId}`);
// ใช้ Raw Query เพราะต้อง Query จาก View และ Filter ด้วย JSON
// v_user_tasks มี assignee_ids_json สำหรับ Filter
// MariaDB 11.8: ใช้ JSON_SEARCH แทน CAST AS JSON
const userIdNum = Number(userId);
const [tasks, countResult] = await Promise.all([
this.dataSource.query(
`
SELECT
instance_id as instanceId,
workflow_code as workflowCode,
current_state as currentState,
entity_type as entityType,
entity_id as entityId,
document_number as documentNumber,
subject,
assigned_at as assignedAt
FROM v_user_tasks
WHERE
JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL
OR owner_id = ?
ORDER BY assigned_at DESC
LIMIT ? OFFSET ?
`,
[userIdNum, userIdNum, limit, offset]
),
this.dataSource.query(
`
SELECT COUNT(*) as total
FROM v_user_tasks
WHERE
JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL
OR owner_id = ?
`,
[userIdNum, userIdNum]
),
]);
const total = parseInt(countResult[0]?.total || '0', 10);
return {
data: tasks,
meta: { total, page, limit },
};
}
}

View File

@@ -0,0 +1,27 @@
// File: src/modules/dashboard/dto/dashboard-stats.dto.ts
// บันทึกการแก้ไข: สร้างใหม่สำหรับ Dashboard Stats Response
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO สำหรับ Response ของ Dashboard Statistics
*/
export class DashboardStatsDto {
@ApiProperty({ description: 'จำนวนเอกสารทั้งหมด', example: 150 })
totalDocuments!: number;
@ApiProperty({ description: 'จำนวนเอกสารเดือนนี้', example: 25 })
documentsThisMonth!: number;
@ApiProperty({ description: 'จำนวนงานที่รออนุมัติ', example: 12 })
pendingApprovals!: number;
@ApiProperty({ description: 'จำนวนเอกสารที่อนุมัติแล้ว', example: 100 })
approved!: number;
@ApiProperty({ description: 'จำนวน RFA ทั้งหมด', example: 45 })
totalRfas!: number;
@ApiProperty({ description: 'จำนวน Circulation ทั้งหมด', example: 30 })
totalCirculations!: number;
}

View File

@@ -0,0 +1,42 @@
// File: src/modules/dashboard/dto/get-activity.dto.ts
// บันทึกการแก้ไข: สร้างใหม่สำหรับ Query params ของ Activity endpoint
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
/**
* DTO สำหรับ Query params ของ GET /dashboard/activity
*/
export class GetActivityDto {
@ApiPropertyOptional({ description: 'จำนวนรายการที่ต้องการ', default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit?: number = 10;
}
/**
* DTO สำหรับ Response ของ Activity Item
*/
export class ActivityItemDto {
@ApiPropertyOptional({ description: 'Action ที่กระทำ' })
action!: string;
@ApiPropertyOptional({ description: 'ประเภท Entity' })
entityType?: string;
@ApiPropertyOptional({ description: 'ID ของ Entity' })
entityId?: string;
@ApiPropertyOptional({ description: 'รายละเอียด' })
details?: Record<string, unknown>;
@ApiPropertyOptional({ description: 'วันที่กระทำ' })
createdAt!: Date;
@ApiPropertyOptional({ description: 'ชื่อผู้ใช้' })
username?: string;
}

View File

@@ -0,0 +1,55 @@
// File: src/modules/dashboard/dto/get-pending.dto.ts
// บันทึกการแก้ไข: สร้างใหม่สำหรับ Query params ของ Pending endpoint
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
/**
* DTO สำหรับ Query params ของ GET /dashboard/pending
*/
export class GetPendingDto {
@ApiPropertyOptional({ description: 'หน้าที่ต้องการ', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'จำนวนรายการต่อหน้า', default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit?: number = 10;
}
/**
* DTO สำหรับ Response ของ Pending Task Item
*/
export class PendingTaskItemDto {
@ApiPropertyOptional({ description: 'Instance ID ของ Workflow' })
instanceId!: string;
@ApiPropertyOptional({ description: 'Workflow Code' })
workflowCode!: string;
@ApiPropertyOptional({ description: 'State ปัจจุบัน' })
currentState!: string;
@ApiPropertyOptional({ description: 'ประเภทเอกสาร' })
entityType!: string;
@ApiPropertyOptional({ description: 'ID ของเอกสาร' })
entityId!: string;
@ApiPropertyOptional({ description: 'เลขที่เอกสาร' })
documentNumber!: string;
@ApiPropertyOptional({ description: 'หัวข้อเรื่อง' })
subject!: string;
@ApiPropertyOptional({ description: 'วันที่ได้รับมอบหมาย' })
assignedAt!: Date;
}

View File

@@ -0,0 +1,6 @@
// File: src/modules/dashboard/dto/index.ts
// บันทึกการแก้ไข: สร้างใหม่สำหรับ export DTOs ทั้งหมด
export * from './dashboard-stats.dto';
export * from './get-activity.dto';
export * from './get-pending.dto';

Some files were not shown because too many files have changed in this diff Show More