Main: revise specs to 1.5.0 (completed)

This commit is contained in:
2025-12-01 01:28:32 +07:00
parent 241022ada6
commit 71c091055a
69 changed files with 28252 additions and 74 deletions

1
.github/workflows/link-checker.yml vendored Normal file
View File

@@ -0,0 +1 @@
# ตรวจสอบ broken links

View File

@@ -1 +1,63 @@
# ตรวจสอบ specs
name: Spec Validation
on:
pull_request:
paths:
- 'specs/**'
- 'diagrams/**'
push:
branches: [main]
jobs:
validate-markdown:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# 1. ตรวจสอบ Markdown syntax
- name: Lint Markdown
uses: avto-dev/markdown-lint@v1
with:
config: '.markdownlint.json'
args: 'specs/**/*.md'
# 2. ตรวจสอบ internal links
- name: Check Links
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: 'yes'
folder-path: 'specs'
# 3. ตรวจสอบ required metadata
- name: Validate Metadata
run: |
python scripts/validate-spec-metadata.py
# 4. ตรวจสอบ version consistency
- name: Check Version Numbers
run: |
python scripts/check-versions.py
validate-diagrams:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# ตรวจสอบว่า Mermaid diagrams render ได้
- name: Validate Mermaid
uses: neenjaw/compile-mermaid-markdown-action@v1
with:
files: 'diagrams/**/*.mmd'
check-todos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# แจ้งเตือนถ้ามี TODO/FIXME
- name: Check for TODOs
run: |
if grep -r "TODO\|FIXME" specs/; then
echo "⚠️ Found TODO/FIXME in specs!"
exit 1
fi

View File

@@ -49,7 +49,8 @@
"wallabyjs.console-ninja",
// Icons & Theme
"pkief.material-icon-theme"
"pkief.material-icon-theme",
"bierner.markdown-mermaid"
// AI Assistance (Optional - เลือก 1 อัน)
// "github.copilot",

View File

@@ -1 +1,666 @@
# How to contribute to specs
# 📝 Contributing to LCBP3-DMS Specifications
> แนวทางการมีส่วนร่วมในการพัฒนาเอกสาร Specifications ของโครงการ LCBP3-DMS
ยินดีต้อนรับสู่คู่มือการมีส่วนร่วมในการพัฒนาเอกสาร Specifications! เอกสารนี้จะช่วยให้คุณเข้าใจวิธีการสร้าง แก้ไข และปรับปรุงเอกสารข้อกำหนดของโครงการได้อย่างมีประสิทธิภาพ
---
## 📚 Table of Contents
- [ภาพรวม Specification Structure](#-specification-structure)
- [หลักการเขียน Specifications](#-writing-principles)
- [Workflow การแก้ไข Specs](#-contribution-workflow)
- [Template และ Guidelines](#-templates--guidelines)
- [Review Process](#-review-process)
- [Best Practices](#-best-practices)
- [Tools และ Resources](#-tools--resources)
---
## 🗂️ Specification Structure
โครงสร้างเอกสาร Specifications ของโครงการแบ่งออกเป็น 6 หมวดหลัก:
```
specs/
├── 00-overview/ # ภาพรวมโครงการ
│ ├── README.md # Project overview
│ └── glossary.md # คำศัพท์เทคนิค
├── 01-requirements/ # ข้อกำหนดระบบ
│ ├── README.md # Requirements overview
│ ├── 01-objectives.md # วัตถุประสงค์
│ ├── 02-architecture.md # สถาปัตยกรรม
│ ├── 03-functional-requirements.md
│ ├── 03.1-project-management.md
│ ├── 03.2-correspondence.md
│ ├── 03.3-rfa.md
│ ├── 03.4-contract-drawing.md
│ ├── 03.5-shop-drawing.md
│ ├── 03.6-unified-workflow.md
│ ├── 03.7-transmittals.md
│ ├── 03.8-circulation-sheet.md
│ ├── 03.9-logs.md
│ ├── 03.10-file-handling.md
│ ├── 03.11-document-numbering.md
│ ├── 03.12-json-details.md
│ ├── 04-access-control.md
│ ├── 05-ui-ux.md
│ ├── 06-non-functional.md
│ └── 07-testing.md
├── 02-architecture/ # สถาปัตยกรรมระบบ
│ ├── README.md
│ ├── system-architecture.md
│ ├── api-design.md
│ └── data-model.md
├── 03-implementation/ # แผนการพัฒนา
│ ├── README.md
│ ├── backend-plan.md
│ ├── frontend-plan.md
│ └── integration-plan.md
├── 04-operations/ # การดำเนินงาน
│ ├── README.md
│ ├── deployment.md
│ └── monitoring.md
└── 05-decisions/ # Architecture Decision Records
├── README.md
├── 001-workflow-engine.md
└── 002-file-storage.md
```
### 📋 หมวดหมู่เอกสาร
| หมวด | วัตถุประสงค์ | ผู้ดูแล |
| --------------------- | ----------------------------- | ----------------------------- |
| **00-overview** | ภาพรวมโครงการและคำศัพท์ | Project Manager |
| **01-requirements** | ข้อกำหนดฟังก์ชันและระบบ | Business Analyst + Tech Lead |
| **02-architecture** | สถาปัตยกรรมและการออกแบบ | Tech Lead + Architects |
| **03-implementation** | แผนการพัฒนาและ Implementation | Development Team Leads |
| **04-operations** | Deployment และ Operations | DevOps Team |
| **05-decisions** | Architecture Decision Records | Tech Lead + Senior Developers |
---
## ✍️ Writing Principles
### 1. ภาษาที่ใช้
- **ชื่อเรื่อง (Headings)**: ภาษาไทยหรืออังกฤษ (ตามบริบท)
- **เนื้อหาหลัก**: ภาษาไทย
- **Code Examples**: ภาษาอังกฤษ
- **Technical Terms**: ภาษาอังกฤษ (พร้อมคำอธิบายภาษาไทย)
### 2. รูปแบบการเขียน
#### ✅ ถูกต้อง
````markdown
## 3.2 การจัดการเอกสารโต้ตอบ (Correspondence Management)
ระบบต้องรองรับการจัดการเอกสารโต้ตอบ (Correspondence) ระหว่างองค์กร โดยมีฟีเจอร์ดังนี้:
- **สร้างเอกสาร**: ผู้ใช้สามารถสร้างเอกสารใหม่ได้
- **แก้ไขเอกสาร**: รองรับการแก้ไข Draft
- **ส่งเอกสาร**: ส่งผ่าน Workflow Engine
### ตัวอย่าง API Endpoint
```typescript
POST /api/correspondences
{
"subject": "Request for Information",
"type_id": 1,
"to_org_id": 2
}
```
````
````
#### ❌ ผิด
```markdown
## correspondence management
ระบบต้องรองรับ correspondence ระหว่างองค์กร
- สร้างได้
- แก้ไขได้
- ส่งได้
````
### 3. โครงสร้างเอกสาร
ทุกเอกสารควรมีโครงสร้างดังนี้:
```markdown
# [ชื่อเอกสาร]
> คำอธิบายสั้นๆ เกี่ยวกับเอกสาร
## Table of Contents (ถ้าเอกสารยาว)
- [Section 1](#section-1)
- [Section 2](#section-2)
## Overview
[ภาพรวมของหัวข้อ]
## [Main Sections]
[เนื้อหาหลัก]
## Related Documents
- [Link to related spec 1]
- [Link to related spec 2]
---
**Last Updated**: 2025-11-30
**Version**: 1.4.5
**Status**: Draft | Review | Approved
```
---
## 🔄 Contribution Workflow
### ขั้นตอนการแก้ไข Specifications
#### 1. สร้าง Issue (ถ้าจำเป็น)
```bash
# ใน Gitea Issues
Title: [SPEC] Update Correspondence Requirements
Description:
- เพิ่มข้อกำหนดการ CC หลายองค์กร
- อัพเดท Workflow diagram
- เพิ่ม validation rules
```
#### 2. สร้าง Branch
```bash
# Naming convention
git checkout -b spec/[category]/[description]
# ตัวอย่าง
git checkout -b spec/requirements/update-correspondence
git checkout -b spec/architecture/add-workflow-diagram
git checkout -b spec/adr/file-storage-strategy
```
#### 3. แก้ไขเอกสาร
```bash
# แก้ไขไฟล์ที่เกี่ยวข้อง
vim specs/01-requirements/03.2-correspondence.md
# ตรวจสอบ markdown syntax
pnpm run lint:markdown
# Preview (ถ้ามี)
pnpm run preview:specs
```
#### 4. Commit Changes
```bash
# Commit message format
git commit -m "spec(requirements): update correspondence CC requirements
- Add support for multiple CC organizations
- Update workflow diagram
- Add validation rules for CC list
- Link to ADR-003
Refs: #123"
# Commit types:
# spec(category): สำหรับการแก้ไข specs
# docs(category): สำหรับเอกสารทั่วไป
# adr(number): สำหรับ Architecture Decision Records
```
#### 5. Push และสร้าง Pull Request
```bash
git push origin spec/requirements/update-correspondence
```
**Pull Request Template:**
```markdown
## 📝 Specification Changes
### Category
- [ ] Requirements
- [ ] Architecture
- [ ] Implementation
- [ ] Operations
- [ ] ADR
### Type of Change
- [ ] New specification
- [ ] Update existing spec
- [ ] Fix typo/formatting
- [ ] Add diagram/example
### Description
[อธิบายการเปลี่ยนแปลง]
### Impact Analysis
- **Affected Modules**: [ระบุ modules ที่ได้รับผลกระทบ]
- **Breaking Changes**: Yes/No
- **Migration Required**: Yes/No
### Related Documents
- Related Specs: [links]
- Related Issues: #123
- Related ADRs: ADR-001
### Checklist
- [ ] เขียนเป็นภาษาไทย (เนื้อหาหลัก)
- [ ] ใช้ Technical terms ภาษาอังกฤษ
- [ ] มี Code examples (ถ้าเกี่ยวข้อง)
- [ ] อัพเดท Table of Contents
- [ ] อัพเดท Last Updated date
- [ ] ตรวจสอบ markdown syntax
- [ ] ตรวจสอบ internal links
- [ ] เพิ่ม Related Documents
```
---
## 📋 Templates & Guidelines
### Template: Functional Requirement
````markdown
## [Feature ID]. [Feature Name]
### วัตถุประสงค์ (Purpose)
[อธิบายว่าฟีเจอร์นี้ทำอะไร และทำไมต้องมี]
### ข้อกำหนดหลัก (Requirements)
#### [REQ-001] [Requirement Title]
**Priority**: High | Medium | Low
**Status**: Proposed | Approved | Implemented
**Description**:
[คำอธิบายข้อกำหนด]
**Acceptance Criteria**:
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
**Technical Notes**:
```typescript
// ตัวอย่าง code หรือ API
```
````
**Related**:
- Dependencies: [REQ-002], [REQ-003]
- Conflicts: None
- ADRs: [ADR-001]
### User Stories
```gherkin
Given [context]
When [action]
Then [expected result]
```
### UI/UX Requirements
[Screenshots, Wireframes, หรือ Mockups]
### Non-Functional Requirements
- **Performance**: [เช่น Response time < 200ms]
- **Security**: [เช่น RBAC required]
- **Scalability**: [เช่น Support 100 concurrent users]
### Test Scenarios
1. **Happy Path**: [อธิบาย]
2. **Edge Cases**: [อธิบาย]
3. **Error Handling**: [อธิบาย]
````
### Template: Architecture Decision Record (ADR)
```markdown
# ADR-[NUMBER]: [Title]
**Status**: Proposed | Accepted | Deprecated | Superseded
**Date**: YYYY-MM-DD
**Deciders**: [ชื่อผู้ตัดสินใจ]
**Technical Story**: [Issue/Epic link]
## Context and Problem Statement
[อธิบายปัญหาและบริบท]
## Decision Drivers
- [Driver 1]
- [Driver 2]
- [Driver 3]
## Considered Options
### Option 1: [Title]
**Pros**:
- [Pro 1]
- [Pro 2]
**Cons**:
- [Con 1]
- [Con 2]
### Option 2: [Title]
[เหมือนข้างบน]
## Decision Outcome
**Chosen option**: "[Option X]"
**Justification**:
[อธิบายเหตุผล]
**Consequences**:
- **Positive**: [ผลดี]
- **Negative**: [ผลเสีย]
- **Neutral**: [ผลกระทบอื่นๆ]
## Implementation
```typescript
// ตัวอย่าง implementation
````
## Validation
[วิธีการตรวจสอบว่า decision นี้ถูกต้อง]
## Related Decisions
- Supersedes: [ADR-XXX]
- Related to: [ADR-YYY]
- Conflicts with: None
## References
- [Link 1]
- [Link 2]
````
---
## 👀 Review Process
### Reviewer Checklist
#### ✅ Content Quality
- [ ] **Clarity**: เนื้อหาชัดเจน เข้าใจง่าย
- [ ] **Completeness**: ครบถ้วนตามโครงสร้าง
- [ ] **Accuracy**: ข้อมูลถูกต้อง ตรงตามความเป็นจริง
- [ ] **Consistency**: สอดคล้องกับ specs อื่นๆ
- [ ] **Traceability**: มี links ไปยังเอกสารที่เกี่ยวข้อง
#### ✅ Technical Quality
- [ ] **Feasibility**: สามารถ implement ได้จริง
- [ ] **Performance**: คำนึงถึง performance implications
- [ ] **Security**: ระบุ security requirements
- [ ] **Scalability**: รองรับการขยายตัว
- [ ] **Maintainability**: ง่ายต่อการบำรุงรักษา
#### ✅ Format & Style
- [ ] **Markdown Syntax**: ไม่มี syntax errors
- [ ] **Language**: ใช้ภาษาไทยสำหรับเนื้อหาหลัก
- [ ] **Code Examples**: มี syntax highlighting
- [ ] **Diagrams**: ชัดเจน อ่านง่าย
- [ ] **Links**: ทุก link ใช้งานได้
### Review Levels
| Level | Reviewer | Scope |
|-------|----------|-------|
| **L1: Peer Review** | Team Member | Format, Clarity, Completeness |
| **L2: Technical Review** | Tech Lead | Technical Accuracy, Feasibility |
| **L3: Approval** | Project Manager | Business Alignment, Impact |
### Review Timeline
- **L1 Review**: 1-2 วันทำการ
- **L2 Review**: 2-3 วันทำการ
- **L3 Approval**: 1-2 วันทำการ
---
## 💡 Best Practices
### 1. เขียนให้ชัดเจนและเฉพาะเจาะจง
#### ✅ ถูกต้อง
```markdown
ระบบต้องรองรับการอัปโหลดไฟล์ประเภท PDF, DWG, DOCX, XLSX, ZIP
โดยมีขนาดไม่เกิน 50MB ต่อไฟล์ และต้องผ่านการ scan virus ด้วย ClamAV
````
#### ❌ ผิด
```markdown
ระบบต้องรองรับการอัปโหลดไฟล์หลายประเภท
```
### 2. ใช้ Diagrams และ Examples
````markdown
### Workflow Diagram
```mermaid
graph LR
A[Draft] --> B[Submitted]
B --> C{Review}
C -->|Approve| D[Approved]
C -->|Reject| E[Rejected]
```
````
````
### 3. อ้างอิงเอกสารที่เกี่ยวข้อง
```markdown
## Related Documents
- Requirements: [03.2-correspondence.md](./03.2-correspondence.md)
- Architecture: [system-architecture.md](../02-architecture/system-architecture.md)
- ADR: [ADR-001: Workflow Engine](../05-decisions/001-workflow-engine.md)
- Implementation: [Backend Plan](../../docs/2_Backend_Plan_V1_4_5.md)
````
### 4. Version Control
```markdown
---
**Document History**:
| Version | Date | Author | Changes |
| ------- | ---------- | ---------- | --------------- |
| 1.0.0 | 2025-01-15 | John Doe | Initial version |
| 1.1.0 | 2025-02-20 | Jane Smith | Add CC support |
| 1.2.0 | 2025-03-10 | John Doe | Update workflow |
**Current Version**: 1.2.0
**Status**: Approved
**Last Updated**: 2025-03-10
```
### 5. ใช้ Consistent Terminology
อ้างอิงจาก [glossary.md](./specs/00-overview/glossary.md) เสมอ
```markdown
- ✅ ใช้: "Correspondence" (เอกสารโต้ตอบ)
- ❌ ไม่ใช้: "Letter", "Document", "Communication"
- ✅ ใช้: "RFA" (Request for Approval)
- ❌ ไม่ใช้: "Approval Request", "Submit for Approval"
```
---
## 🛠️ Tools & Resources
### Markdown Tools
```bash
# Lint markdown files
pnpm run lint:markdown
# Fix markdown issues
pnpm run lint:markdown:fix
# Preview specs (if available)
pnpm run preview:specs
```
### Recommended VS Code Extensions
```json
{
"recommendations": [
"yzhang.markdown-all-in-one",
"DavidAnson.vscode-markdownlint",
"bierner.markdown-mermaid",
"shd101wyy.markdown-preview-enhanced",
"streetsidesoftware.code-spell-checker"
]
}
```
### Markdown Linting Rules
Create `.markdownlint.json`:
```json
{
"default": true,
"MD013": false,
"MD033": false,
"MD041": false
}
```
### Diagram Tools
- **Mermaid**: สำหรับ flowcharts, sequence diagrams
- **PlantUML**: สำหรับ UML diagrams
- **Draw.io**: สำหรับ architecture diagrams
### Reference Documents
- [Markdown Guide](https://www.markdownguide.org/)
- [Mermaid Documentation](https://mermaid-js.github.io/)
- [ADR Template](https://github.com/joelparkerhenderson/architecture-decision-record)
---
## 📞 Getting Help
### คำถามเกี่ยวกับ Specs
1. **ตรวจสอบเอกสารที่มีอยู่**: [specs/](./specs/)
2. **ดู Glossary**: [specs/00-overview/glossary.md](./specs/00-overview/glossary.md)
3. **ค้นหา Issues**: [Gitea Issues](https://git.np-dms.work/lcbp3/lcbp3-dms/issues)
4. **ถาม Team**: [ช่องทางการติดต่อ]
### การรายงานปัญหา
```markdown
**Title**: [SPEC] [Category] [Brief description]
**Description**:
- **Current State**: [อธิบายปัญหาปัจจุบัน]
- **Expected State**: [อธิบายสิ่งที่ควรจะเป็น]
- **Affected Documents**: [ระบุเอกสารที่เกี่ยวข้อง]
- **Proposed Solution**: [เสนอแนะวิธีแก้ไข]
**Labels**: spec, [category]
```
---
## 🎯 Quality Standards
### Definition of Done (DoD) สำหรับ Spec Changes
- [x] เนื้อหาครบถ้วนตามโครงสร้าง
- [x] ใช้ภาษาไทยสำหรับเนื้อหาหลัก
- [x] มี code examples (ถ้าเกี่ยวข้อง)
- [x] มี diagrams (ถ้าจำเป็น)
- [x] อัพเดท Table of Contents
- [x] อัพเดท Last Updated date
- [x] ผ่าน markdown linting
- [x] ตรวจสอบ internal links
- [x] เพิ่ม Related Documents
- [x] ผ่าน L1 Peer Review
- [x] ผ่าน L2 Technical Review
- [x] ได้รับ L3 Approval
---
## 📜 License & Copyright
เอกสาร Specifications ทั้งหมดเป็นทรัพย์สินของโครงการ LCBP3-DMS
**Internal Use Only** - ห้ามเผยแพร่ภายนอก
---
## 🙏 Acknowledgments
ขอบคุณทุกท่านที่มีส่วนร่วมในการพัฒนาเอกสาร Specifications ของโครงการ!
---
<div align="center">
**Questions?** Contact the Tech Lead or Project Manager
[Specs Directory](./specs) • [Main README](./README.md) • [Documentation](./docs)
</div>

510
README.md
View File

@@ -1 +1,509 @@
# Project documentation hub
# 📋 LCBP3-DMS - Document Management System
> **Laem Chabang Port Phase 3 - Document Management System**
>
> ระบบบริหารจัดการเอกสารโครงการแบบครบวงจร สำหรับโครงการก่อสร้างท่าเรือแหลมฉบังระยะที่ 3
[![Version](https://img.shields.io/badge/version-1.4.5-blue.svg)](./CHANGELOG.md)
[![License](https://img.shields.io/badge/license-Internal-red.svg)]()
[![Status](https://img.shields.io/badge/status-In%20Development-yellow.svg)]()
---
## 🎯 ภาพรวมโครงการ
LCBP3-DMS เป็นระบบบริหารจัดการเอกสารโครงการที่ออกแบบมาเพื่อรองรับการทำงานของโครงการก่อสร้างขนาดใหญ่ โดยเน้นที่:
- **ความปลอดภัยสูงสุด** - Security-first approach ด้วย RBAC 4 ระดับ
- **ความถูกต้องของข้อมูล** - Data Integrity ผ่าน Transaction และ Locking Mechanisms
- **ความยืดหยุ่น** - Unified Workflow Engine รองรับ Workflow ที่ซับซ้อน
- **ความทนทาน** - Resilience Patterns และ Error Handling ที่ครอบคลุม
### ✨ ฟีเจอร์หลัก
- 📝 **Correspondence Management** - จัดการเอกสารโต้ตอบระหว่างองค์กร
- 🔧 **RFA Management** - ระบบขออนุมัติเอกสารทางเทคนิค
- 📐 **Drawing Management** - จัดการแบบก่อสร้างและแบบคู่สัญญา
- 🔄 **Workflow Engine** - DSL-based workflow สำหรับกระบวนการอนุมัติ
- 📊 **Advanced Search** - ค้นหาเอกสารด้วย Elasticsearch
- 🔐 **RBAC 4-Level** - ควบคุมสิทธิ์แบบละเอียด (Global, Organization, Project, Contract)
- 📁 **Two-Phase File Storage** - จัดการไฟล์แบบ Transactional พร้อม Virus Scanning
- 🔢 **Document Numbering** - สร้างเลขที่เอกสารอัตโนมัติ ป้องกัน Race Condition
---
## 🏗️ สถาปัตยกรรมระบบ
### Technology Stack
#### Backend (NestJS)
```typescript
{
"framework": "NestJS (TypeScript, ESM)",
"database": "MariaDB 10.11",
"orm": "TypeORM",
"authentication": "JWT + Passport",
"authorization": "CASL (RBAC)",
"search": "Elasticsearch",
"cache": "Redis",
"queue": "BullMQ",
"fileUpload": "Multer + ClamAV",
"notification": "Nodemailer + n8n (LINE)",
"documentation": "Swagger"
}
```
#### Frontend (Next.js)
```typescript
{
"framework": "Next.js 14+ (App Router)",
"language": "TypeScript",
"styling": "Tailwind CSS",
"components": "shadcn/ui",
"stateManagement": {
"server": "TanStack Query (React Query)",
"forms": "React Hook Form + Zod",
"ui": "useState/useReducer"
},
"testing": "Vitest + Playwright"
}
```
#### Infrastructure
- **Server**: QNAP TS-473A (AMD Ryzen V1500B, 32GB RAM)
- **Containerization**: Docker + Docker Compose (Container Station)
- **Reverse Proxy**: Nginx Proxy Manager
- **Version Control**: Gitea (Self-hosted)
- **Domain**: `np-dms.work`
### โครงสร้างระบบ
```
┌─────────────────┐
│ Nginx Proxy │ ← SSL/TLS Termination
│ Manager │
└────────┬────────┘
┌────┴────┬────────────┬──────────┐
│ │ │ │
┌───▼───┐ ┌──▼──┐ ┌─────▼────┐ ┌──▼──┐
│Next.js│ │NestJS│ │Elasticsearch│ │ n8n │
│Frontend│ │Backend│ │ Search │ │Workflow│
└───────┘ └──┬──┘ └──────────┘ └─────┘
┌────────┼────────┐
│ │ │
┌───▼───┐ ┌─▼──┐ ┌──▼────┐
│MariaDB│ │Redis│ │ClamAV │
│ DB │ │Cache│ │ Scan │
└───────┘ └────┘ └───────┘
```
---
## 🚀 เริ่มต้นใช้งาน
### ข้อกำหนดระบบ
- **Node.js**: v20.x หรือสูงกว่า
- **pnpm**: v8.x หรือสูงกว่า
- **Docker**: v24.x หรือสูงกว่า
- **MariaDB**: 10.11
- **Redis**: 7.x
### การติดตั้ง
#### 1. Clone Repository
```bash
git clone https://git.np-dms.work/lcbp3/lcbp3-dms.git
cd lcbp3-dms
```
#### 2. ติดตั้ง Dependencies
```bash
# ติดตั้ง dependencies ทั้งหมด (backend + frontend)
pnpm install
```
#### 3. ตั้งค่า Environment Variables
**Backend:**
```bash
cd backend
cp .env.example .env
# แก้ไข .env ตามความเหมาะสม
```
**Frontend:**
```bash
cd frontend
cp .env.local.example .env.local
# แก้ไข .env.local ตามความเหมาะสม
```
#### 4. ตั้งค่า Database
```bash
# Import schema
mysql -u root -p lcbp3_dev < docs/8_lcbp3_v1_4_5.sql
# Import seed data
mysql -u root -p lcbp3_dev < docs/8_lcbp3_v1_4_5_seed.sql
```
#### 5. รัน Development Server
**Backend:**
```bash
cd backend
pnpm run start:dev
```
**Frontend:**
```bash
cd frontend
pnpm run dev
```
### การเข้าถึงระบบ
- **Frontend**: `http://localhost:3000`
- **Backend API**: `http://localhost:3001`
- **API Documentation**: `http://localhost:3001/api`
### ข้อมูลเข้าสู่ระบบเริ่มต้น
```
Superadmin:
Username: admin@np-dms.work
Password: (ดูใน seed data)
```
---
## 📁 โครงสร้างโปรเจกต์
```
lcbp3-dms/
├── backend/ # NestJS Backend
│ ├── src/
│ │ ├── common/ # Shared modules
│ │ ├── modules/ # Feature modules
│ │ │ ├── auth/
│ │ │ ├── user/
│ │ │ ├── project/
│ │ │ ├── correspondence/
│ │ │ ├── rfa/
│ │ │ ├── drawing/
│ │ │ ├── workflow-engine/
│ │ │ └── ...
│ │ └── main.ts
│ ├── test/
│ └── package.json
├── frontend/ # Next.js Frontend
│ ├── app/ # App Router
│ ├── components/ # React Components
│ ├── lib/ # Utilities
│ └── package.json
├── docs/ # 📚 เอกสารโครงการ
│ ├── 0_Requirements_V1_4_5.md
│ ├── 1_FullStackJS_V1_4_5.md
│ ├── 2_Backend_Plan_V1_4_5.md
│ ├── 3_Frontend_Plan_V1_4_5.md
│ ├── 4_Data_Dictionary_V1_4_5.md
│ ├── 8_lcbp3_v1_4_5.sql
│ └── 8_lcbp3_v1_4_5_seed.sql
├── infrastructure/ # Docker & Deployment
│ └── Markdown/ # Legacy docs
└── pnpm-workspace.yaml
```
---
## 📚 เอกสารประกอบ
### เอกสารหลัก
| เอกสาร | คำอธิบาย | ไฟล์ |
| ------------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| **Requirements** | ข้อกำหนดระบบและฟังก์ชันการทำงาน | [0_Requirements_V1_4_5.md](./docs/0_Requirements_V1_4_5.md) |
| **Full Stack Guidelines** | แนวทางการพัฒนา TypeScript/NestJS/Next.js | [1_FullStackJS_V1_4_5.md](./docs/1_FullStackJS_V1_4_5.md) |
| **Backend Plan** | แผนการพัฒนา Backend แบบ Phase-Based | [2_Backend_Plan_V1_4_5.md](./docs/2_Backend_Plan_V1_4_5.md) |
| **Frontend Plan** | แผนการพัฒนา Frontend | [3_Frontend_Plan_V1_4_5.md](./docs/3_Frontend_Plan_V1_4_5.md) |
| **Data Dictionary** | โครงสร้างฐานข้อมูลและ Entity Relationships | [4_Data_Dictionary_V1_4_5.md](./docs/4_Data_Dictionary_V1_4_5.md) |
### เอกสารเพิ่มเติม
- **Database Schema**: [8_lcbp3_v1_4_5.sql](./docs/8_lcbp3_v1_4_5.sql)
- **Seed Data**: [8_lcbp3_v1_4_5_seed.sql](./docs/8_lcbp3_v1_4_5_seed.sql)
- **Changelog**: [CHANGELOG.md](./CHANGELOG.md)
- **Contributing**: [CONTRIBUTING.md](./CONTRIBUTING.md)
---
## 🔧 Development Guidelines
### Coding Standards
#### ภาษาที่ใช้
- **Code**: ภาษาอังกฤษ (English)
- **Comments & Documentation**: ภาษาไทย (Thai)
#### TypeScript Rules
```typescript
// ✅ ถูกต้อง
interface User {
user_id: number; // Property: snake_case
firstName: string; // Variable: camelCase
isActive: boolean; // Boolean: Verb + Noun
}
// ❌ ผิด
interface User {
userId: number; // ไม่ใช้ camelCase สำหรับ property
first_name: string; // ไม่ใช้ snake_case สำหรับ variable
active: boolean; // ไม่ใช้ Verb + Noun
}
```
#### File Naming
```
user-service.ts ✅ kebab-case
UserService.ts ❌ PascalCase
user_service.ts ❌ snake_case
```
### Git Workflow
```bash
# สร้าง feature branch
git checkout -b feature/correspondence-module
# Commit message format
git commit -m "feat(correspondence): add create correspondence endpoint"
# Types: feat, fix, docs, style, refactor, test, chore
```
### Testing
```bash
# Backend
cd backend
pnpm test # Unit tests
pnpm test:e2e # E2E tests
pnpm test:cov # Coverage
# Frontend
cd frontend
pnpm test # Unit tests
pnpm test:e2e # Playwright E2E
```
---
## 🔐 Security
### Security Features
-**JWT Authentication** - Access & Refresh Tokens
-**RBAC 4-Level** - Global, Organization, Project, Contract
-**Rate Limiting** - ป้องกัน Brute-force
-**Virus Scanning** - ClamAV สำหรับไฟล์ที่อัปโหลด
-**Input Validation** - ป้องกัน SQL Injection, XSS, CSRF
-**Idempotency** - ป้องกันการทำรายการซ้ำ
-**Audit Logging** - บันทึกการกระทำทั้งหมด
### Security Best Practices
1. **ห้ามเก็บ Secrets ใน Git**
- ใช้ `.env` สำหรับ Development
- ใช้ `docker-compose.override.yml` (gitignored)
2. **Password Policy**
- ความยาวขั้นต่ำ: 8 ตัวอักษร
- ต้องมี uppercase, lowercase, number, special character
- เปลี่ยน password ทุก 90 วัน
3. **File Upload**
- White-list file types: PDF, DWG, DOCX, XLSX, ZIP
- Max size: 50MB
- Virus scan ทุกไฟล์
---
## 🧪 Testing Strategy
### Test Pyramid
```
/\
/ \ E2E Tests (10%)
/____\
/ \ Integration Tests (20%)
/________\
/ \ Unit Tests (70%)
/____________\
```
### Coverage Goals
- **Backend**: 70%+ overall
- Business Logic: 80%+
- Controllers: 70%+
- Utilities: 90%+
- **Frontend**: 60%+ overall
---
## 📊 Monitoring & Observability
### Health Checks
```bash
# Backend health
curl http://localhost:3001/health
# Database health
curl http://localhost:3001/health/db
# Redis health
curl http://localhost:3001/health/redis
```
### Metrics
- API Response Time
- Error Rates
- Cache Hit Ratio
- Database Connection Pool
- File Upload Performance
---
## 🚢 Deployment
### Production Deployment
```bash
# Build backend
cd backend
pnpm run build
# Build frontend
cd frontend
pnpm run build
# Deploy with Docker Compose
docker-compose -f docker-compose.yml up -d
```
### Environment-specific Configs
- **Development**: `.env`, `docker-compose.override.yml`
- **Staging**: Environment variables ใน Container Station
- **Production**: Docker secrets หรือ Vault
---
## 🤝 Contributing
กรุณาอ่าน [CONTRIBUTING.md](./CONTRIBUTING.md) สำหรับรายละเอียดเกี่ยวกับ:
- Code of Conduct
- Development Process
- Pull Request Process
- Coding Standards
---
## 📝 License
This project is **Internal Use Only** - ลิขสิทธิ์เป็นของโครงการ LCBP3
---
## 👥 Team
- **Project Manager**: [ระบุชื่อ]
- **Tech Lead**: [ระบุชื่อ]
- **Backend Team**: [ระบุชื่อ]
- **Frontend Team**: [ระบุชื่อ]
---
## 📞 Support
สำหรับคำถามหรือปัญหา กรุณาติดต่อ:
- **Email**: support@np-dms.work
- **Internal Chat**: [ระบุช่องทาง]
- **Issue Tracker**: [Gitea Issues](https://git.np-dms.work/lcbp3/lcbp3-dms/issues)
---
## 🗺️ Roadmap
### Version 1.4.5 (Current)
- ✅ Core Infrastructure
- ✅ Authentication & Authorization
- 🔄 Correspondence Module
- 🔄 RFA Module
- 🔄 Workflow Engine
### Version 1.5.0 (Planned)
- 📋 Advanced Reporting
- 📊 Dashboard Analytics
- 🔔 Enhanced Notifications
- 📱 Mobile App
---
## 📖 Additional Resources
### API Documentation
- Swagger UI: `http://localhost:3001/api`
- Postman Collection: [ดาวน์โหลด](./docs/postman/)
### Architecture Diagrams
- [System Architecture](./diagrams/system-architecture.md)
- [Database ERD](./diagrams/database-erd.md)
- [Workflow Engine](./diagrams/workflow-engine.md)
### Learning Resources
- [NestJS Documentation](https://docs.nestjs.com/)
- [Next.js Documentation](https://nextjs.org/docs)
- [TypeORM Documentation](https://typeorm.io/)
---
<div align="center">
**Built with ❤️ for LCBP3 Project**
[Documentation](./docs) • [Issues](https://git.np-dms.work/lcbp3/lcbp3-dms/issues) • [Changelog](./CHANGELOG.md)
</div>

714
docs/temp.md Normal file
View File

@@ -0,0 +1,714 @@
# 🚀 GitHub Spec Kit: การใช้งานร่วมกับ Requirements & FullStackJS v1.4.5
## ðŸ" ภาพรวม
GitHub Spec Kit เป็นเครื่องมือที่ช่วยให้คุณสร้าง **Issue Specifications** ที่มีโครงสร้างชัดเจนและครบถ้วนสำหรับโปรเจค LCBP3-DMS โดยอ้างอิง Requirements v1.4.5 และ FullStackJS v1.4.5
---
## 🎯 วัตถุประสงค์หลัก
1. **แปลง Requirements เป็น Actionable Tasks** - แบ่งความต้องการขนาดใหญ่เป็น issues ขนาดเล็กที่ทำได้จริง
2. **รักษา Standards** - ทุก issue ต้องสอดคล้องกับ FullStackJS Guidelines
3. **Traceability** - อ้างอิงกลับไปยัง Requirements ได้ทุกครั้ง
4. **Consistency** - ใช้ template และรูปแบบเดียวกันทั้งโปรเจค
---
## ðŸ"š โครงสร้าง Spec Kit
### 1. **Issue Title Format**
```
[<Module>] <FeatureType>: <ShortDescription>
```
**ตัวอย่าง:**
- `[RFA] Feature: Implement RFA Creation Workflow`
- `[Auth] Security: Add Rate Limiting to Login Endpoint`
- `[File] Refactor: Implement Two-Phase Storage Strategy`
### 2. **Issue Labels**
- **Type:** `feature`, `bug`, `refactor`, `security`, `performance`
- **Priority:** `critical`, `high`, `medium`, `low`
- **Module:** `backend`, `frontend`, `database`, `infrastructure`
- **Status:** `blocked`, `in-progress`, `review-needed`
### 3. **Issue Template Structure**
```markdown
## 📋 Overview
[Brief description of what needs to be done]
## 🎯 Requirements Reference
- **Section:** [Requirements v1.4.5 - Section X.X]
- **Related:** [Link to specific requirement]
## ðŸ'» Technical Specifications
### Backend (NestJS)
- Module: `<ModuleName>`
- Files to modify:
- [ ] `src/modules/<module>/<file>.ts`
- Dependencies: `<npm packages>`
### Frontend (Next.js)
- Pages/Components:
- [ ] `app/<page>/page.tsx`
- Dependencies: `<npm packages>`
### Database
- Tables affected: `<table_names>`
- Migrations needed: [ ] Yes / [ ] No
## ✅ Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Security checks passed
- [ ] Tests written and passing
- [ ] Documentation updated
## 🧪 Testing Requirements
- [ ] Unit tests (70% coverage minimum)
- [ ] Integration tests
- [ ] E2E tests (if applicable)
## 🔒 Security Checklist
- [ ] Input validation implemented
- [ ] RBAC permissions checked
- [ ] Audit logging added
- [ ] No sensitive data in logs
## ðŸ"— Related Issues
- Blocks: #<issue_number>
- Depends on: #<issue_number>
- Related to: #<issue_number>
## ðŸ"Œ Notes
[Any additional context, decisions, or constraints]
```
---
## ðŸ› ï¸ วิธีการใช้งาน
### **Step 1: ระบุ Feature จาก Requirements**
อ้างอิง: `0_Requirements_V1_4_5.md`
**ตัวอย่าง:**
```
Section 3.3: การจัดการเอกสารขออนุมัติ (RFA)
```
### **Step 2: สร้าง Issue Specification**
**Title:**
```
[RFA] Feature: Implement RFA Creation with Workflow Engine
```
**Overview:**
```markdown
## 📋 Overview
Implement the RFA (Request for Approval) creation workflow that allows
Document Control users to create RFAs with multiple revisions and trigger
the unified workflow engine.
## 🎯 Requirements Reference
- **Section:** Requirements v1.4.5 - Section 3.3
- **Page Reference:** Lines 250-275
- **Key Requirements:**
- Support PDF file uploads only [3.3.2]
- Multiple revisions support [3.3.2]
- Draft/Submitted state management [3.3.3]
- Integration with Unified Workflow Engine [3.3.5]
```
### **Step 3: กำหนด Technical Specs ตาม FullStackJS**
อ้างอิง: `1_FullStackJS_V1_4_5.md`
````markdown
## ðŸ'» Technical Specifications
### Backend (NestJS)
- **Module:** `RfaModule` [Section 3.9.7]
- **Files to modify:**
- [ ] `src/modules/rfa/rfa.controller.ts`
- [ ] `src/modules/rfa/rfa.service.ts`
- [ ] `src/modules/rfa/dto/create-rfa.dto.ts`
- [ ] `src/modules/workflow/workflow-engine.service.ts`
- **Dependencies:**
```json
{
"@nestjs/typeorm": "^10.0.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1"
}
```
````
- **Implementation Guidelines:**
- Use Two-Phase Storage for file uploads [Section 3.3]
- Apply Optimistic Locking for race conditions [Section 3.2.1]
- Integrate with DocumentNumberingService [Section 3.9.12]
- Follow RBAC permissions [Section 5.4]
### Frontend (Next.js)
- **Pages/Components:**
- [ ] `app/rfas/new/page.tsx`
- [ ] `components/rfa/rfa-form.tsx`
- [ ] `components/rfa/revision-manager.tsx`
- **Dependencies:**
```json
{
"react-hook-form": "^7.50.0",
"zod": "^3.22.0",
"@tanstack/react-query": "^5.20.0"
}
```
- **State Management:** [Section 4.10]
- Use React Query for server state
- Use React Hook Form for form state
- Implement auto-save drafts to localStorage [Section 4.1.1]
### Database
- **Tables affected:**
- `rfas` - main table
- `rfa_revisions` - revision history
- `rfa_items` - related items
- `workflow_instances` - workflow state
- **Migrations needed:** [ ✓ ] Yes
- Create migration for virtual columns if using JSON [Section 3.2.2]
````
### **Step 4: กำหนด Acceptance Criteria**
```markdown
## ✅ Acceptance Criteria
### Functional Requirements
- [ ] Document Control users can create new RFAs
- [ ] Support PDF file upload (max 50MB) [Section 3.10.1]
- [ ] RFA can have multiple revisions [Section 3.3.2]
- [ ] RFA number auto-generated using format template [Section 3.11]
- [ ] Draft state allows editing, Submitted state is read-only [Section 3.3.3]
- [ ] Triggers workflow engine on submission [Section 3.3.5]
### Non-Functional Requirements
- [ ] API response time < 200ms (excluding file processing) [Section 6.4]
- [ ] Virus scanning implemented for all uploads [Section 3.10.2]
- [ ] File integrity checks (checksum) performed [Section 3.10.3]
- [ ] Audit log entry created for all actions [Section 6.1]
- [ ] RBAC permissions enforced (rfas.create) [Section 5.4.2]
### Security Requirements
- [ ] Input validation using class-validator [Section 2.1]
- [ ] Rate limiting applied (1000 req/hour for Editor) [Section 6.5.1]
- [ ] No sensitive data in error messages [Section 6.5.4]
- [ ] Download links expire after 24 hours [Section 3.10.2]
````
### **Step 5: กำหนด Testing Requirements**
```markdown
## 🧪 Testing Requirements
### Unit Tests (Jest) [Section 3.14]
- [ ] RfaService.create() - success case
- [ ] RfaService.create() - validation failures
- [ ] RfaService.create() - file upload failures
- [ ] RfaService.create() - race condition handling
- [ ] DocumentNumberingService integration
- **Target Coverage:** 70% minimum [Section 7.1]
### Integration Tests (Supertest) [Section 7.2]
- [ ] POST /api/rfas - with valid file upload
- [ ] POST /api/rfas - with invalid file type
- [ ] POST /api/rfas - with virus-infected file
- [ ] POST /api/rfas - without required permissions
- [ ] Workflow engine integration test
### E2E Tests (Playwright) [Section 7.3]
- [ ] Complete RFA creation flow
- [ ] File upload with drag-and-drop
- [ ] Form auto-save and recovery
- [ ] Error handling and user feedback
### Security Tests [Section 7.4]
- [ ] SQL injection attempts blocked
- [ ] XSS attacks prevented
- [ ] File upload security bypass attempts
- [ ] Rate limiting enforcement
```
### **Step 6: Security Checklist**
```markdown
## 🔒 Security Checklist [Section 6.5]
### Input Validation
- [ ] All DTOs use class-validator [Section 3.1]
- [ ] File type white-list enforced (PDF only) [Section 3.10.1]
- [ ] File size limit checked (50MB max) [Section 3.10.1]
- [ ] JSON schema validation for details field [Section 3.12]
### Authentication & Authorization
- [ ] JWT token validation [Section 5.1]
- [ ] RBAC permission check (rfas.create) [Section 5.4]
- [ ] Organization scope validation [Section 4.2]
- [ ] Session tracking implemented [Section 6.5.4]
### Data Protection
- [ ] Audit log entry created [Section 8.1]
- [ ] Sensitive data not logged [Section 6.5.4]
- [ ] File integrity checksum stored [Section 3.10.3]
- [ ] Virus scanning completed [Section 3.10.2]
### Rate Limiting [Section 6.5.1]
- [ ] Editor: 1000 req/hour
- [ ] File Upload: 50 req/hour
- [ ] Fallback for rate limiter failures
```
---
## ðŸ"Š ตัวอย่าง Issue ที่สมบูรณ์
### Issue #1: [RFA] Feature: Implement RFA Creation with Workflow Engine
````markdown
## 📋 Overview
Implement the RFA (Request for Approval) creation workflow that allows
Document Control users to create RFAs with multiple revisions and trigger
the unified workflow engine.
## 🎯 Requirements Reference
- **Section:** Requirements v1.4.5 - Section 3.3
- **Lines:** 250-275
- **Key Requirements:**
- วัตถุประสงค์: เอกสารขออนุมัติ (RFA) ภายใน โครงการ [3.3.1]
- ประเภทเอกสาร: รองรับเอกสารรูปแบบ ไฟล์ PDF [3.3.2]
- รองรับ Multiple Revisions [3.3.2]
- Draft/Submitted State Management [3.3.3]
- Unified Workflow Integration [3.3.5]
## ðŸ'» Technical Specifications
### Backend (NestJS)
**Module:** `RfaModule` [FullStackJS Section 3.9.7]
**Files to create/modify:**
- [ ] `src/modules/rfa/rfa.controller.ts`
- [ ] `src/modules/rfa/rfa.service.ts`
- [ ] `src/modules/rfa/dto/create-rfa.dto.ts`
- [ ] `src/modules/rfa/entities/rfa.entity.ts`
- [ ] `src/modules/rfa/entities/rfa-revision.entity.ts`
- [ ] `src/modules/workflow/workflow-engine.service.ts`
**Dependencies:**
```json
{
"@nestjs/typeorm": "^10.0.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"uuid": "^9.0.0"
}
```
````
**Implementation Guidelines:**
1. **File Storage:** Use Two-Phase Storage Strategy [FullStackJS 3.3]
- Phase 1: Upload to `temp/` with virus scan
- Phase 2: Move to `permanent/{YYYY}/{MM}/` on commit
2. **Concurrency:** Apply Optimistic Locking [FullStackJS 3.2.1]
```typescript
@VersionColumn()
version: number;
```
3. **Document Numbering:** [FullStackJS 3.9.12]
- Inject `DocumentNumberingService`
- Use Redis distributed lock for counter
- Format: `{ORG}-RFA-{DISCIPLINE}-{SEQ:4}-{REV}`
4. **Workflow Integration:** [FullStackJS 3.9.14]
- Call `WorkflowEngineService.createInstance()`
- Set initial state to 'DRAFT'
- Transition to workflow on submission
### Frontend (Next.js)
**Pages/Components:**
- [ ] `app/rfas/new/page.tsx` - Main creation page
- [ ] `components/rfa/rfa-form.tsx` - Form component
- [ ] `components/rfa/revision-manager.tsx` - Revision UI
- [ ] `components/file-upload/multi-file-upload.tsx` - File upload
**Dependencies:**
```json
{
"react-hook-form": "^7.50.0",
"zod": "^3.22.0",
"@tanstack/react-query": "^5.20.0",
"react-dropzone": "^14.2.3"
}
```
**State Management:** [FullStackJS 4.10]
- **Server State:** React Query for RFA data
- **Form State:** React Hook Form + Zod validation
- **Auto-save:** LocalStorage for drafts [FullStackJS 4.1.1]
**File Upload UX:** [FullStackJS 5.7]
```typescript
// Multi-file drag-and-drop
// Distinguish between "Main Document" and "Supporting Attachments"
// Show virus scan progress
// Display file type icons and security warnings
```
### Database
**Tables affected:**
- `rfas` - Main RFA table
- `rfa_revisions` - Revision history
- `rfa_items` - Related items
- `workflow_instances` - Workflow state
- `attachments` - File metadata
- `rfa_attachments` - Junction table
**Migrations needed:** [ ✓ ] Yes
```sql
-- Example migration structure
CREATE TABLE rfas (
rfa_id INT PRIMARY KEY AUTO_INCREMENT,
project_id INT NOT NULL,
rfa_number VARCHAR(100) UNIQUE NOT NULL,
current_state VARCHAR(50),
version INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
FOREIGN KEY (project_id) REFERENCES projects(project_id)
);
```
## ✅ Acceptance Criteria
### Functional Requirements
- [ ] Document Control users can create new RFAs
- [ ] Support PDF file upload (max 50MB) [Req 3.10.1]
- [ ] RFA can have multiple revisions [Req 3.3.2]
- [ ] RFA number auto-generated: `{ORG}-RFA-{DISCIPLINE}-{SEQ:4}-{REV}` [Req 3.11]
- [ ] Draft state allows editing, Submitted state is read-only [Req 3.3.3]
- [ ] Admin can cancel/revert submitted RFAs [Req 3.3.3]
- [ ] Triggers unified workflow engine on submission [Req 3.3.5]
- [ ] Can reference Shop Drawings in RFA [Req 3.3.4]
### Non-Functional Requirements
- [ ] API response time < 200ms (excluding file processing) [Req 6.4]
- [ ] File upload completes < 30 seconds for 50MB [Req 6.4]
- [ ] Virus scanning implemented (ClamAV) [Req 3.10.2]
- [ ] File integrity checks (checksum) performed [Req 3.10.3]
- [ ] Audit log entry created for all actions [Req 6.1]
- [ ] RBAC permissions enforced (rfas.create, rfas.respond) [Req 5.4.2]
### UI/UX Requirements [FullStackJS 5.7]
- [ ] Form uses React Hook Form + Zod validation
- [ ] Multi-file drag-and-drop interface
- [ ] Clear distinction between main document and attachments
- [ ] Real-time virus scan feedback
- [ ] File type indicators and security warnings
- [ ] Auto-save draft to localStorage every 30 seconds
- [ ] Mobile-responsive (Card View on small screens)
### Security Requirements [Req 6.5]
- [ ] Input validation using class-validator
- [ ] Rate limiting: Editor 1000 req/hour, File Upload 50 req/hour
- [ ] No sensitive data in error messages
- [ ] Download links expire after 24 hours
- [ ] Files stored outside web root
- [ ] Access control checks before file download
## 🧪 Testing Requirements
### Unit Tests (Jest) [FullStackJS 3.14]
```typescript
describe('RfaService', () => {
describe('create', () => {
it('should create RFA with valid data');
it('should reject invalid file types');
it('should handle race conditions with optimistic locking');
it('should generate correct RFA number');
it('should trigger workflow on submission');
});
});
```
- **Target Coverage:** 70% minimum [Req 7.1]
### Integration Tests (Supertest) [FullStackJS 3.14]
```typescript
describe('POST /api/rfas', () => {
it('should create RFA with file upload');
it('should reject virus-infected files');
it('should enforce RBAC permissions');
it('should create workflow instance');
});
```
### E2E Tests (Playwright) [FullStackJS 4.9]
```typescript
test('Complete RFA creation flow', async ({ page }) => {
// Login as Document Control user
// Navigate to RFA creation page
// Fill form with test data
// Upload main document (PDF)
// Upload supporting attachment
// Verify auto-save works
// Submit RFA
// Verify workflow starts
});
```
### Security Tests [Req 7.4]
- [ ] SQL injection attempts blocked
- [ ] XSS attacks prevented
- [ ] File upload security bypass attempts
- [ ] Rate limiting enforcement
- [ ] Unauthorized access attempts blocked
## 🔒 Security Checklist [Req 6.5]
### Input Validation
- [ ] All DTOs use class-validator decorators
- [ ] File type white-list enforced (PDF only)
- [ ] File size limit checked (50MB max)
- [ ] JSON schema validation for details field
- [ ] Sanitize all user inputs
### Authentication & Authorization
- [ ] JWT token validation on all endpoints
- [ ] RBAC permission check (rfas.create, rfas.respond)
- [ ] Organization scope validation
- [ ] Project membership validation
- [ ] Session tracking and concurrent session limits
### Data Protection
- [ ] Audit log entry for: create, update, submit, cancel
- [ ] Sensitive data not logged (file contents, user emails)
- [ ] File integrity checksum stored in database
- [ ] Virus scanning before file is accessible
- [ ] Encrypted storage for sensitive metadata
### Rate Limiting [Req 6.5.1]
- [ ] Document Control: 2000 req/hour
- [ ] File Upload: 50 req/hour per user
- [ ] Graceful degradation if Redis unavailable
- [ ] Alert on rate limit violations
## ðŸ"— Related Issues
- **Depends on:**
- #<issue> FileStorageService implementation
- #<issue> WorkflowEngine implementation
- #<issue> DocumentNumberingService implementation
- **Blocks:**
- #<issue> RFA Response/Approval workflow
- #<issue> RFA Reports and Analytics
- **Related to:**
- #<issue> Shop Drawing reference system
- #<issue> Unified Workflow Engine
## ðŸ"Œ Notes
### Design Decisions
1. **Why Two-Phase Storage?**
- Prevents orphan files if transaction fails
- Allows virus scanning before commitment
- Reference: FullStackJS Section 3.3
2. **Why Application-level Locking?**
- More portable than database stored procedures
- Better observability with Redis
- Reference: FullStackJS Section 3.9.12
3. **Why Optimistic Locking?**
- Better performance than pessimistic locks
- Handles concurrent updates gracefully
- Reference: FullStackJS Section 3.2.1
### Implementation Order
1. Backend: Entity, DTO, Service (with tests)
2. Backend: Controller, Guards, Interceptors
3. Database: Migration scripts
4. Frontend: Form component (with validation)
5. Frontend: File upload component
6. Frontend: Integration with API
7. E2E: Complete user flow test
### Risk Mitigation
- **File Upload Failures:** Implement retry with exponential backoff
- **Virus Scan Downtime:** Queue files for later scanning, block download
- **Database Contention:** Redis lock + Optimistic lock dual protection
- **Large Files:** Stream uploads, show progress indicator
---
## ðŸ"Œ Labels
`feature` `backend` `frontend` `database` `high-priority` `requires-testing`
## 👤 Assignment
- **Backend:** @backend-team
- **Frontend:** @frontend-team
- **QA:** @qa-team
## ⏱ï¸ Estimated Effort
- **Backend:** 5 days
- **Frontend:** 3 days
- **Testing:** 2 days
- **Total:** 10 days
````
---
## ðŸ"§ Best Practices
### 1. **ความละเอียดที่เหมาะสม**
- ✅ **DO:** แบ่ง issue ให้ทำเสร็จภายใน 1-2 สัปดาห์
- ❌ **DON'T:** สร้าง issue ขนาดใหญ่ที่ใช้เวลาเป็นเดือน
### 2. **การอ้างอิง**
- ✅ **DO:** อ้างอิง section และ line numbers จาก Requirements
- ✅ **DO:** อ้างอิง section จาก FullStackJS Guidelines
- ❌ **DON'T:** Copy-paste ข้อความยาวๆ ทั้งหมด
### 3. **Acceptance Criteria**
- ✅ **DO:** เขียนเป็นข้อๆ ที่ตรวจสอบได้ (testable)
- ✅ **DO:** แยก functional และ non-functional requirements
- ❌ **DON'T:** เขียนคลุมเครือ เช่น "ระบบต้องเร็ว"
### 4. **Testing**
- ✅ **DO:** ระบุ test cases ที่ต้องเขียนชัดเจน
- ✅ **DO:** ระบุ coverage target (70% minimum)
- ❌ **DON'T:** ปล่อยให้ "เขียน test ภายหลัง"
### 5. **Security**
- ✅ **DO:** มี Security Checklist ทุก issue ที่เกี่ยวกับ user input
- ✅ **DO:** ระบุ permission ที่ต้องใช้ชัดเจน
- ❌ **DON'T:** คิดว่า "จะเพิ่ม security ทีหลัง"
---
## ðŸ"Š Issue Workflow
```mermaid
graph LR
A[Create Issue from Req] --> B[Add Technical Specs]
B --> C[Define Acceptance Criteria]
C --> D[Add Testing Requirements]
D --> E[Security Checklist]
E --> F[Link Related Issues]
F --> G[Assign & Estimate]
G --> H[Ready for Development]
````
---
## ✅ สรุป: ขั้นตอนการสร้าง Issue ที่สมบูรณ์
1. **ระบุ Requirement** - เลือก section จาก Requirements v1.4.5
2. **สร้าง Title** - ใช้รูปแบบ `[Module] Type: Description`
3. **เขียน Overview** - สรุปสั้นๆ ว่าต้องทำอะไร
4. **อ้างอิง Requirements** - ระบุ section และ line numbers
5. **กำหนด Technical Specs** - แยกเป็น Backend, Frontend, Database
6. **ใช้ FullStackJS Guidelines** - อ้างอิง section ที่เกี่ยวข้อง
7. **เขียน Acceptance Criteria** - แยก functional/non-functional
8. **ระบุ Testing Requirements** - Unit, Integration, E2E
9. **Security Checklist** - ครอบคลุม OWASP Top 10
10. **Link Related Issues** - ระบุ dependencies และ blockers
11. **เพิ่ม Labels & Assignment** - ใส่ label และมอบหมายงาน
12. **Estimate Effort** - ประมาณการเวลาที่ใช้
---
**เอกสารนี้เป็น Living Document และจะถูกปรับปรุงตามความต้องการของทีมพัฒนา**

View File

@@ -1,21 +1,24 @@
{
"folders": [+
{
"name": "🗓️ Documents",
"path": "./Documnets"
},
{
"name": "🔧 Backend",
"path": "./backend"
},
{
"name": "🎨 Frontend",
"path": "./frontend"
},
{
"name": "🎯 Root",
"path": "./"
},
{
"name": "🗓️ docs",
"path": "./docs"
},
{
"name": "🔗 specs",
"path": "./specs"
},
{
"name": "🔧 Backend",
"path": "./backend"
},
{
"name": "🎨 Frontend",
"path": "./frontend"
}
],
"settings": {

View File

@@ -1 +1,402 @@
# Project overview
# LCBP3-DMS - Project Overview
**Project Name:** Laem Chabang Port Phase 3 - Document Management System (LCBP3-DMS)
**Version:** 1.5.0
**Status:** Planning & Specification Phase
**Last Updated:** 2025-12-01
---
## 📋 Table of Contents
- [Project Introduction](#-project-introduction)
- [Key Features](#-key-features)
- [Technology Stack](#-technology-stack)
- [Project Structure](#-project-structure)
- [Quick Links](#-quick-links)
- [Getting Started](#-getting-started)
- [Team & Stakeholders](#-team--stakeholders)
---
## 🎯 Project Introduction
LCBP3-DMS is a comprehensive Document Management System (DMS) designed specifically for the Laem Chabang Port Phase 3 construction project. The system manages construction documents, workflows, approvals, and communications between multiple organizations including port authority, consultants, contractors, and third parties.
### Project Objectives
1. **Centralize Document Management** - Single source of truth for all project documents
2. **Streamline Workflows** - Automated routing and approval processes
3. **Improve Collaboration** - Real-time access for all stakeholders
4. **Ensure Compliance** - Audit trails and document version control
5. **Enhance Efficiency** - Reduce paper-based processes and manual routing
### Project Scope
**In Scope:**
- Correspondence Management (Letters & Communications)
- RFA (Request for Approval) Management
- Drawing Management (Contract & Shop Drawings)
- Workflow Engine (Approvals & Routing)
- Document Numbering System
- File Storage & Management
- Search & Reporting
- User & Access Management
- Audit Logs & Notifications
**Out of Scope:**
- Financial Management & Billing
- Procurement & Material Management
- Project Scheduling (Gantt Charts)
- HR & Payroll Systems
- Mobile App (Phase 1 only)
---
## ✨ Key Features
### 📨 Correspondence Management
- Create, review, and track official letters
- Master-Revision pattern for version control
- Multi-level approval workflows
- Attachment management
- Automatic document numbering
### 📋 RFA Management
- Submit requests for approval
- Item-based RFA structure
- Response tracking (Approved/Approved with Comments/Rejected)
- Revision management
- Integration with workflow engine
### 📐 Drawing Management
- Contract Drawings (แบบคู่สัญญา)
- Shop Drawings (แบบก่อสร้าง) with revisions
- Version control & comparison
- Drawing linking and references
### ⚙️ Workflow Engine
- DSL-based workflow configuration
- Dynamic routing based on rules
- Parallel & sequential approvals
- Escalation & timeout handling
- Workflow history & audit trail
### 🗄️ Document Numbering
- Automatic number generation
- Template-based formatting
- Discipline-specific numbering
- Concurrent request handling (Double-lock mechanism)
- Annual reset support
### 🔍 Search & Discovery
- Full-text search (Elasticsearch)
- Advanced filtering
- Document metadata search
- Quick access to recent documents
### 🔐 Security & Access Control
- 4-Level Hierarchical RBAC (Global/Organization/Project/Contract)
- JWT-based authentication
- Permission-based access control
- Audit logging
- Session management
### 📧 Notifications
- Multi-channel (Email, LINE Notify, In-app)
- Workflow event notifications
- Customizable user preferences
- Async delivery (Queue-based)
---
## 🛠️ Technology Stack
### Backend
- **Framework:** NestJS (TypeScript)
- **Database:** MariaDB 10.11
- **Cache & Queue:** Redis 7.2
- **Search:** Elasticsearch 8.11
- **ORM:** TypeORM
- **Authentication:** JWT (JSON Web Tokens)
- **Authorization:** CASL (4-Level RBAC)
- **File Processing:** ClamAV (Virus Scanning)
- **Queue:** BullMQ
### Frontend
- **Framework:** Next.js 14+ (App Router)
- **Language:** TypeScript
- **Styling:** Tailwind CSS
- **UI Components:** Shadcn/UI
- **State Management:** React Context / Zustand
- **Forms:** React Hook Form + Zod
- **API Client:** Axios
### Infrastructure
- **Deployment:** Docker & Docker Compose
- **Platform:** QNAP Container Station
- **Reverse Proxy:** NGINX
- **Logging:** Winston
- **Monitoring:** Health Checks + Log Aggregation
---
## 📁 Project Structure
```
lcbp3/
├── backend/ # NestJS Backend Application
│ ├── src/
│ │ ├── modules/ # Feature modules
│ │ ├── common/ # Shared utilities
│ │ ├── config/ # Configuration
│ │ └── migrations/ # Database migrations
│ ├── test/ # Tests
│ └── package.json
├── frontend/ # Next.js Frontend Application
│ ├── app/ # App router pages
│ ├── components/ # React components
│ ├── lib/ # Utilities
│ └── package.json
├── docs/ # Source documentation
│ ├── 0_Requirements_V1_4_5.md
│ ├── 1_FullStackJS_V1_4_5.md
│ ├── 2_Backend_Plan_V1_4_4.md
│ ├── 3_Frontend_Plan_V1_4_4.md
│ └── 4_Data_Dictionary_V1_4_5.md
├── specs/ # Technical Specifications
│ ├── 00-overview/ # Project overview & glossary
│ ├── 01-requirements/ # Functional requirements
│ ├── 02-architecture/ # System architecture
│ ├── 03-implementation/ # Implementation guidelines
│ ├── 04-operations/ # Deployment & operations
│ ├── 05-decisions/ # Architecture Decision Records (ADRs)
│ └── 06-tasks/ # Development tasks
├── docker-compose.yml # Docker services configuration
└── README.md # Project README
```
---
## 🔗 Quick Links
### Documentation
| Category | Document | Description |
| ------------------ | --------------------------------------------------------------------------- | ------------------------------------- |
| **Overview** | [Glossary](./glossary.md) | Technical terminology & abbreviations |
| **Overview** | [Quick Start](./quick-start.md) | 5-minute getting started guide |
| **Requirements** | [Functional Requirements](../01-requirements/03-functional-requirements.md) | Feature specifications |
| **Architecture** | [System Architecture](../02-architecture/system-architecture.md) | Overall system design |
| **Architecture** | [Data Model](../02-architecture/data-model.md) | Database schema |
| **Architecture** | [API Design](../02-architecture/api-design.md) | REST API specifications |
| **Implementation** | [Backend Guidelines](../03-implementation/backend-guidelines.md) | Backend coding standards |
| **Implementation** | [Frontend Guidelines](../03-implementation/frontend-guidelines.md) | Frontend coding standards |
| **Implementation** | [Testing Strategy](../03-implementation/testing-strategy.md) | Testing approach |
| **Operations** | [Deployment Guide](../04-operations/deployment-guide.md) | How to deploy |
| **Operations** | [Monitoring](../04-operations/monitoring-alerting.md) | Monitoring & alerts |
| **Decisions** | [ADR Index](../05-decisions/README.md) | Architecture decisions |
| **Tasks** | [Backend Tasks](../06-tasks/README.md) | Development tasks |
### Key ADRs
1. [ADR-001: Unified Workflow Engine](../05-decisions/ADR-001-unified-workflow-engine.md)
2. [ADR-002: Document Numbering Strategy](../05-decisions/ADR-002-document-numbering-strategy.md)
3. [ADR-003: Two-Phase File Storage](../05-decisions/ADR-003-file-storage-approach.md)
4. [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
5. [ADR-005: Technology Stack](../05-decisions/ADR-005-technology-stack.md)
---
## 🚀 Getting Started
### For Developers
1. **Read Documentation**
- Start with [Quick Start Guide](./quick-start.md)
- Review [System Architecture](../02-architecture/system-architecture.md)
- Study [Backend](../03-implementation/backend-guidelines.md) / [Frontend](../03-implementation/frontend-guidelines.md) guidelines
2. **Setup Development Environment**
- Clone repository
- Install Docker & Docker Compose
- Run `docker-compose up`
- Access backend: `http://localhost:3000`
- Access frontend: `http://localhost:3001`
3. **Start Coding**
- Pick a task from [Backend Tasks](../06-tasks/README.md)
- Follow coding guidelines
- Write tests
- Submit PR for review
### For Operations Team
1. **Infrastructure Setup**
- Review [Environment Setup](../04-operations/environment-setup.md)
- Configure QNAP Container Station
- Setup Docker Compose
2. **Deployment**
- Follow [Deployment Guide](../04-operations/deployment-guide.md)
- Configure [Backup & Recovery](../04-operations/backup-recovery.md)
- Setup [Monitoring](../04-operations/monitoring-alerting.md)
3. **Maintenance**
- Review [Maintenance Procedures](../04-operations/maintenance-procedures.md)
- Setup [Incident Response](../04-operations/incident-response.md)
- Configure [Security Operations](../04-operations/security-operations.md)
---
## 👥 Team & Stakeholders
### Project Team
- **System Architect:** Nattanin Peancharoen
- **Backend Team Lead:** [Name]
- **Frontend Team Lead:** [Name]
- **DevOps Engineer:** [Name]
- **QA Lead:** [Name]
- **Database Administrator:** [Name]
### Stakeholders
- **Port Authority of Thailand (กทท.)** - Owner
- **Project Supervisors (สค©.)** - Consultants
- **Design Consultants (TEAM)** - Designers
- **Construction Supervisors (คคง.)** - Supervision
- **Contractors (ผรม.1-4)** - Construction
---
## 📊 Project Timeline
### Phase 1: Foundation (Weeks 1-4)
- Database setup & migrations
- Authentication & RBAC
- **Milestone:** User can login
### Phase 2: Core Infrastructure (Weeks 5-10)
- User Management & Master Data
- File Storage & Document Numbering
- Workflow Engine
- **Milestone:** Core services ready
### Phase 3: Business Modules (Weeks 11-17)
- Correspondence Management
- RFA Management
- **Milestone:** Core documents manageable
### Phase 4: Supporting Modules (Weeks 18-21)
- Drawing Management
- Circulation & Transmittal
- Search & Elasticsearch
- **Milestone:** Document ecosystem complete
### Phase 5: Services (Week 22)
- Notifications & Audit Logs
- **Milestone:** MVP ready for UAT
### Phase 6: Testing & Deployment (Weeks 23-24)
- User Acceptance Testing (UAT)
- Production deployment
- **Milestone:** Go-Live
---
## 📈 Success Metrics
### Technical Metrics
- **Uptime:** > 99.5%
- **API Response Time (P95):** < 500ms
- **Error Rate:** < 1%
- **Database Query Time (P95):** < 100ms
### Business Metrics
- **User Adoption:** > 90% of stakeholders using system
- **Document Processing Time:** 50% reduction vs manual
- **Search Success Rate:** > 95%
- **User Satisfaction:** > 4.0/5.0
---
## 🔐 Security & Compliance
- **Data Encryption:** At rest & in transit
- **Access Control:** 4-level RBAC
- **Audit Logging:** All user actions logged
- **Backup:** Daily automated backups
- **Disaster Recovery:** RTO 4h, RPO 24h
- **Security Scanning:** Automated vulnerability scans
---
## 📞 Support & Contact
### Development Support
- **Repository:** [Internal Git Repository]
- **Issue Tracker:** [Internal Issue Tracker]
- **Documentation:** This repository `/specs`
### Operations Support
- **Email:** ops-team@example.com
- **Phone:** [Phone Number]
- **On-Call:** [On-Call Schedule]
---
## 📝 Document Control
- **Version:** 1.5.0
- **Status:** Active
- **Last Updated:** 2025-12-01
- **Next Review:** 2026-01-01
- **Owner:** System Architect
- **Classification:** Internal Use Only
---
## 🔄 Version History
| Version | Date | Description |
| ------- | ---------- | ------------------------------------------ |
| 1.5.0 | 2025-12-01 | Complete specification with ADRs and tasks |
| 1.4.5 | 2025-11-30 | Updated architecture documents |
| 1.4.4 | 2025-11-29 | Initial backend/frontend plans |
| 1.0.0 | 2025-11-01 | Initial requirements |
---
**Welcome to LCBP3-DMS Project! 🚀**

View File

@@ -1 +1,496 @@
# คำศัพท์เทคนิค
# Glossary - คำศัพท์และคำย่อทางเทคนิค
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 General Terms (คำศัพท์ทั่วไป)
### A
**ADR (Architecture Decision Record)**
เอกสารบันทึกการตัดสินใจทางสถาปัตยกรรมที่สำคัญ พร้อมบริบท ทางเลือก และเหตุผล
**API (Application Programming Interface)**
ชุดคำสั่งและโปรโตคอลที่ใช้สำหรับการสื่อสารระหว่างระบบ
**APM (Application Performance Monitoring)**
การติดตามประสิทธิภาพของแอปพลิเคชัน
**Async (Asynchronous)**
การทำงานแบบไม่ต้องรอให้งานก่อนหน้าเสร็จก่อน
**Attachment**
ไฟล์แนบที่อยู่กับเอกสาร เช่น PDF, Word, Drawing files
**Audit Log**
บันทึกการกระทำของผู้ใช้ในระบบเพื่อการตรวจสอบ
**Authentication**
การยืนยันตัวตนผู้ใช้ (Login)
**Authorization**
การกำหนดสิทธิ์การเข้าถึง
---
### B
**Backend**
ส่วนของระบบที่ทำงานฝั่งเซิร์ฟเวอร์ จัดการข้อมูลและ Business Logic
**Backup**
การสำรองข้อมูล
**Blue-Green Deployment**
กลยุทธ์การ Deploy โดยมี 2 สภาพแวดล้อม (Blue และ Green) สลับกันใช้งาน
**BullMQ**
Message Queue library สำหรับ Node.js ที่ใช้ Redis
---
### C
**Cache**
ที่เก็บข้อมูลชั่วคราวเพื่อเพิ่มความเร็วในการเข้าถึง
**CASL (Component Ability Serialization Language)**
Library สำหรับจัดการ Authorization และ Permissions
**CI/CD (Continuous Integration / Continuous Deployment)**
กระบวนการอัตโนมัติในการ Build, Test และ Deploy code
**ClamAV**
Antivirus software แบบ Open-source สำหรับสแกนไวรัส
**Container**
หน่วยของ Software ที่รวม Application และ Dependencies ทั้งหมด
**CORS (Cross-Origin Resource Sharing)**
กลไกที่อนุญาตให้เว็บคุยข้ามโดเมน
**CRUD (Create, Read, Update, Delete)**
การดำเนินการพื้นฐานกับข้อมูล
---
### D
**Database Migration**
การเปลี่ยนแปลง Schema ของฐานข้อมูลอย่างเป็นระบบ
**DBA (Database Administrator)**
ผู้ดูแลระบบฐานข้อมูล
**DevOps**
แนวทางที่รวม Development และ Operations เข้าด้วยกัน
**Discipline**
สาขาวิชาชีพ เช่น GEN (General), STR (Structure), ARC (Architecture)
**DMS (Document Management System)**
ระบบจัดการเอกสาร
**Docker**
Platform สำหรับพัฒนาและรัน Application ใน Container
**DTO (Data Transfer Object)**
Object ที่ใช้สำหรับส่งข้อมูลระหว่าง Layer ต่างๆ
**DSL (Domain-Specific Language)**
ภาษาที่ออกแบบมาสำหรับโดเมนเฉพาะ
---
### E
**Elasticsearch**
Search Engine แบบ Distributed สำหรับ Full-text Search
**Entity**
Object ที่แทนตารางในฐานข้อมูล (TypeORM)
**ENV (Environment)**
สภาพแวดล้อมการทำงาน เช่น Development, Staging, Production
**Escalation**
การส่งต่อเรื่องไปยังผู้มีอำนาจสูงขึ้น
---
### F
**Foreign Key (FK)**
คีย์ที่เชื่อมโยงระหว่างตาราง
**Frontend**
ส่วนของระบบที่ผู้ใช้โต้ตอบได้ (User Interface)
---
### G
**Guard**
Middleware ใน NestJS ที่ใช้ตรวจสอบ Authorization
**GUI (Graphical User Interface)**
ส่วนติดต่อผู้ใช้แบบกราฟิก
---
### H
**Health Check**
การตรวจสอบสถานะของ Service ว่าทำงานปกติหรือไม่
**Hot Reload**
การ Reload code โดยไม่ต้อง Restart server
---
### I
**Idempotency**
การดำเนินการที่ให้ผลลัพธ์เดียวกันไม่ว่าจะทำกี่ครั้ง
**Incident**
เหตุการณ์ที่ทำให้ระบบไม่สามารถทำงานได้ตามปกติ
**Index**
โครงสร้างข้อมูลที่ช่วยเพิ่มความเร็วในการค้นหา (Database)
**Interceptor**
Middleware ใน NestJS ที่ดัก Request/Response
---
### J
**JWT (JSON Web Token)**
มาตรฐานสำหรับ Token-based Authentication
---
### K
**Key-Value Store**
ฐานข้อมูลที่เก็บข้อมูลในรูปแบบ Key และ Value (เช่น Redis)
---
### L
**LCBP3 (Laem Chabang Port Phase 3)**
โครงการท่าเรือแหลมฉบังระยะที่ 3
**Load Balancer**
ตัวกระจายโหลดไปยัง Server หลายตัว
**Lock**
กลไกป้องกันการเข้าถึงข้อมูลพร้อมกัน
**Log**
บันทึกเหตุการณ์ที่เกิดขึ้นในระบบ
---
### M
**MariaDB**
ฐานข้อมูล Relational แบบ Open-source
**Master Data**
ข้อมูลหลักของระบบ เช่น Organizations, Projects
**Master-Revision Pattern**
รูปแบบการจัดเก็บข้อมูลที่มี Master record และ Revision records
**Microservices**
สถาปัตยกรรมที่แบ่งระบบเป็น Service เล็กๆ หลายตัว
**Migration**
การย้ายหรือเปลี่ยนแปลง Schema ของฐานข้อมูล
**Modular Monolith**
Monolithic application ที่แบ่งโมดูลชัดเจน
**MTBF (Mean Time Between Failures)**
เวลาเฉลี่ยระหว่างความล้มเหลว
**MTTR (Mean Time To Resolution/Repair)**
เวลาเฉลี่ยในการแก้ไขปัญหา
**MVP (Minimum Viable Product)**
ผลิตภัณฑ์ขั้นต่ำที่ใช้งานได้
---
### N
**NestJS**
Framework สำหรับสร้าง Backend Node.js application
**Next.js**
Framework สำหรับสร้าง React application
**NGINX**
Web Server และ Reverse Proxy
---
### O
**ORM (Object-Relational Mapping)**
เทคนิคแปลง Object เป็น Relational Database
**Optimistic Locking**
กลไกป้องกัน Concurrent update โดยใช้ Version
---
### P
**Pessimistic Locking**
กลไกป้องกัน Concurrent access โดย Lock ทันที
**PIR (Post-Incident Review)**
การทบทวนหลังเกิดปัญหา
**Primary Key (PK)**
คีย์หลักของตาราง
**Production**
สภาพแวดล้อมที่ผู้ใช้จริงใช้งาน
---
### Q
**QNAP**
ยี่ห้อ NAS (Network Attached Storage)
**Queue**
แถวลำดับงานที่รอการประมวลผล
---
### R
**Race Condition**
สถานการณ์ที่ผลลัพธ์ขึ้นกับลำดับเวลาการทำงาน
**RBAC (Role-Based Access Control)**
การควบคุมการเข้าถึงตามบทบาท
**Redis**
In-memory Key-Value store สำหรับ Cache และ Queue
**Repository Pattern**
รูปแบบการออกแบบสำหรับการเข้าถึงข้อมูล
**REST (Representational State Transfer)**
สถาปัตยกรรม API ที่ใช้ HTTP
**Rollback**
การย้อนกลับไปสถานะก่อนหน้า
**RPO (Recovery Point Objective)**
จุดเวลาที่ยอมรับได้สำหรับการกู้คืนข้อมูล
**RTO (Recovery Time Objective)**
เวลาที่ยอมรับได้สำหรับการกู้คืนระบบ
---
### S
**Seed Data**
ข้อมูลเริ่มต้นที่ใส่ในฐานข้อมูล
**Session**
ช่วงเวลาที่ผู้ใช้ Login อยู่
**Soft Delete**
การลบข้อมูลโดยทำ Mark แทนการลบจริง
**SQL (Structured Query Language)**
ภาษาสำหรับจัดการฐานข้อมูล
**SSL/TLS (Secure Sockets Layer / Transport Layer Security)**
โปรโตคอลสำหรับการเข้ารหัสข้อมูล
**Staging**
สภาพแวดล้อมสำหรับทดสอบก่อน Production
**State Machine**
โมเดลที่มีหลาย State และ Transition
---
### T
**Temp (Temporary)**
ชั่วคราว
**Transaction**
ชุดการดำเนินการที่ต้องสำเร็จทั้งหมดหรือไม่ทำเลย
**Two-Phase Storage**
การจัดเก็บไฟล์แบบ 2 ขั้นตอน (Temp → Permanent)
**TypeORM**
ORM สำหรับ TypeScript/JavaScript
**TypeScript**
ภาษาโปรแกรมที่เป็น Superset ของ JavaScript พร้อม Static Typing
---
### U
**UAT (User Acceptance Testing)**
การทดสอบโดยผู้ใช้จริง
**UUID (Universally Unique Identifier)**
รหัสไม่ซ้ำกัน 128-bit
---
### V
**Validation**
การตรวจสอบความถูกต้องของข้อมูล
**Version Control**
การควบคุมเวอร์ชันของ Code (เช่น Git)
**Volume**
พื้นที่เก็บข้อมูลถาวรใน Docker
---
### W
**Webhook**
HTTP Callback ที่เรียกเมื่อเกิด Event
**Winston**
Logging library สำหรับ Node.js
**Workflow**
ลำดับขั้นตอนการทำงาน
---
## 🏗️ Project-Specific Terms (คำศัพท์เฉพาะโครงการ)
### Organizations (องค์กร)
**กทท. (Port Authority of Thailand)**
การท่าเรือแห่งประเทศไทย - เจ้าของโครงการ
**สค©. (Supervision Consultant)**
ที่ปรึกษาควบคุมงาน
**TEAM (Design Consultant)**
ที่ปรึกษาออกแบบ
**คคง. (Construction Supervision)**
ผู้ควบคุมงานก่อสร้าง
**ผรม. (Contractor)**
ผู้รับเหมาก่อสร้าง
---
### Document Types
**Correspondence**
เอกสารโต้ตอบ / หนังสือราชการ
**RFA (Request for Approval)**
เอกสารขออนุมัติ
**Contract Drawing**
แบบคู่สัญญา
**Shop Drawing**
แบบก่อสร้าง / แบบการผลิต
**Transmittal**
เอกสารนำส่ง
**Circulation Sheet**
ใบเวียนเอกสารภายใน
---
### Workflow States
**Draft**
ร่างเอกสาร
**Pending**
รอดำเนินการ
**In Review**
อยู่ระหว่างตรวจสอบ
**Approved**
อนุมัติ
**Rejected**
ไม่อนุมัติ
**Closed**
ปิดเรื่อง
---
### Disciplines (สาขาวิชาชีพ)
**GEN - General**
ทั่วไป
**STR - Structure**
โครงสร้าง
**ARC - Architecture**
สถาปัตยกรรม
**MEP - Mechanical, Electrical & Plumbing**
ระบบเครื่องกล ไฟฟ้า และสุขาภิบาล
**CIV - Civil**
โยธา
---
## 📚 Acronyms Reference (อ้างอิงตัวย่อ)
| Acronym | Full Form | Thai |
| ------- | --------------------------------- | ------------------------------- |
| ADR | Architecture Decision Record | บันทึกการตัดสินใจทางสถาปัตยกรรม |
| API | Application Programming Interface | ส่วนต่อประสานโปรแกรม |
| CRUD | Create, Read, Update, Delete | สร้าง อ่าน แก้ไข ลบ |
| DMS | Document Management System | ระบบจัดการเอกสาร |
| DTO | Data Transfer Object | วัตถุถ่ายโอนข้อมูล |
| JWT | JSON Web Token | โทเคนเว็บ JSON |
| LCBP3 | Laem Chabang Port Phase 3 | ท่าเรือแหลมฉบังระยะที่ 3 |
| MVP | Minimum Viable Product | ผลิตภัณฑ์ขั้นต่ำที่ใช้งานได้ |
| ORM | Object-Relational Mapping | การแมปวัตถุกับฐานข้อมูล |
| RBAC | Role-Based Access Control | การควบคุมการเข้าถึงตามบทบาท |
| REST | Representational State Transfer | การถ่ายโอนสถานะแบบนำเสนอ |
| RFA | Request for Approval | เอกสารขออนุมัติ |
| RTO | Recovery Time Objective | เวลาเป้าหมายในการกู้คืน |
| RPO | Recovery Point Objective | จุดเป้าหมายในการกู้คืน |
| UAT | User Acceptance Testing | การทดสอบการยอมรับของผู้ใช้ |
---
**Version:** 1.5.0
**Last Updated:** 2025-12-01
**Next Review:** 2026-03-01

View File

@@ -0,0 +1,389 @@
# Quick Start Guide
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## ⚡ 5-Minute Quick Start
This guide will get you up and running with LCBP3-DMS in 5 minutes.
---
## 👨‍💻 For Developers
### Prerequisites
```bash
# Required
- Docker 20.10+
- Docker Compose 2.0+
- Git
- Node.js 18+ (for local development)
# Recommended
- VS Code
- Postman or similar API testing tool
```
### Step 1: Clone Repository
```bash
git clone <repository-url>
cd lcbp3
```
### Step 2: Setup Environment
```bash
# Backend
cp backend/.env.example backend/.env
# Edit backend/.env and set required values
# Frontend
cp frontend/.env.example frontend/.env.local
# Edit frontend/.env.local
```
### Step 3: Start Services
```bash
# Start all services with Docker Compose
docker-compose up -d
# Check status
docker-compose ps
```
### Step 4: Initialize Database
```bash
# Run migrations
docker exec lcbp3-backend npm run migration:run
# (Optional) Seed sample data
docker exec lcbp3-backend npm run seed
```
### Step 5: Access Application
```bash
# Backend API
http://localhost:3000
# Health check
curl http://localhost:3000/health
# Frontend
http://localhost:3001
# API Documentation (Swagger)
http://localhost:3000/api/docs
```
### Step 6: Login
**Default Admin Account:**
- Username: `admin`
- Password: `Admin@123` (Change immediately!)
---
## 🧪 For QA/Testers
### Running Tests
```bash
# Backend unit tests
docker exec lcbp3-backend npm test
# Backend e2e tests
docker exec lcbp3-backend npm run test:e2e
# Frontend tests
docker exec lcbp3-frontend npm test
```
### Test Data
```bash
# Reset database to clean state
docker exec lcbp3-backend npm run migration:revert
docker exec lcbp3-backend npm run migration:run
# Load test data
docker exec lcbp3-backend npm run seed
```
---
## 🚀 For DevOps
### Deploy to Staging
```bash
# Build images
docker-compose build
# Push to registry
docker-compose push
# Deploy to staging server
ssh staging-server
cd /app/lcbp3
git pull
docker-compose pull
docker-compose up -d
# Run migrations
docker exec lcbp3-backend npm run migration:run
```
### Deploy to Production
```bash
# Backup database first!
./scripts/backup-database.sh
# Deploy with zero-downtime
./scripts/zero-downtime-deploy.sh
# Verify deployment
curl -f https://lcbp3-dms.example.com/health
```
---
## 📊 For Project Managers
### View Project Status
**Documentation:**
- Requirements: [specs/01-requirements](../01-requirements/)
- Architecture: [specs/02-architecture](../02-architecture/)
- Tasks: [specs/06-tasks](../06-tasks/)
**Metrics:**
- Check [Monitoring Dashboard](http://localhost:9200) (if setup)
- Review [Task Board](../06-tasks/README.md)
---
## 🔍 Common Tasks
### Create New Module
```bash
# Backend
cd backend
nest g module modules/my-module
nest g controller modules/my-module
nest g service modules/my-module
# Follow backend guidelines
# See: specs/03-implementation/backend-guidelines.md
```
### Create New Migration
```bash
# Generate migration from entity changes
docker exec lcbp3-backend npm run migration:generate -- -n MigrationName
# Create empty migration
docker exec lcbp3-backend npm run migration:create -- -n MigrationName
# Run migrations
docker exec lcbp3-backend npm run migration:run
# Revert last migration
docker exec lcbp3-backend npm run migration:revert
```
### View Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker logs lcbp3-backend -f --tail=100
# Search logs
docker logs lcbp3-backend 2>&1 | grep "ERROR"
```
### Database Access
```bash
# MySQL CLI
docker exec -it lcbp3-mariadb mysql -u root -p
# Run SQL file
docker exec -i lcbp3-mariadb mysql -u root -p lcbp3_dms < script.sql
```
### Redis Access
```bash
# Redis CLI
docker exec -it lcbp3-redis redis-cli -a <password>
# Check keys
docker exec lcbp3-redis redis-cli -a <password> KEYS "*"
# Clear cache
docker exec lcbp3-redis redis-cli -a <password> FLUSHDB
```
---
## 🐛 Troubleshooting
### Backend not starting
```bash
# Check logs
docker logs lcbp3-backend
# Common issues:
# 1. Database connection - check DB_HOST in .env
# 2. Redis connection - check REDIS_HOST in .env
# 3. Port conflict - check if port 3000 is free
```
### Database connection failed
```bash
# Check if MariaDB is running
docker ps | grep mariadb
# Test connection
docker exec lcbp3-mariadb mysqladmin ping -h localhost
# Check credentials
docker exec lcbp3-backend env | grep DB_
```
### Frontend build failed
```bash
# Clear cache and rebuild
docker exec lcbp3-frontend rm -rf .next node_modules
docker exec lcbp3-frontend npm install
docker exec lcbp3-frontend npm run build
```
### Port already in use
```bash
# Find process using port
lsof -i :3000
# Kill process
kill -9 <PID>
# Or change port in docker-compose.yml
```
---
## 📚 Next Steps
### Learn More
1. **Architecture** - [System Architecture](../02-architecture/system-architecture.md)
2. **Development** - [Backend Guidelines](../03-implementation/backend-guidelines.md)
3. **Deployment** - [Deployment Guide](../04-operations/deployment-guide.md)
4. **Decisions** - [ADR Index](../05-decisions/README.md)
### Join the Team
1. Read [Contributing Guidelines](../../CONTRIBUTING.md)
2. Pick a task from [Backend Tasks](../06-tasks/README.md)
3. Create a branch: `git checkout -b feature/my-feature`
4. Make changes and write tests
5. Submit Pull Request
---
## 💡 Tips & Best Practices
### Development
- ✅ Always write tests for new features
- ✅ Follow coding guidelines
- ✅ Use TypeScript strict mode
- ✅ Add JSDoc for public APIs
- ✅ Keep Pull Requests small
### Git Workflow
```bash
# Update main branch
git checkout main
git pull
# Create feature branch
git checkout -b feature/my-feature
# Make commits
git add .
git commit -m "feat: add new feature"
# Push and create PR
git push origin feature/my-feature
```
### Code Review
- Review [Backend Guidelines](../03-implementation/backend-guidelines.md)
- Check test coverage
- Verify documentation updated
- Run linter: `npm run lint`
- Run tests: `npm test`
---
## 🆘 Getting Help
### Resources
- **Documentation:** `/specs` directory
- **API Docs:** http://localhost:3000/api/docs
- **Issue Tracker:** [Link to issue tracker]
### Contact
- **Tech Lead:** [Email]
- **DevOps:** [Email]
- **Slack:** #lcbp3-dms
---
## ✅ Checklist for First Day
- [ ] Clone repository
- [ ] Install prerequisites
- [ ] Setup environment variables
- [ ] Start Docker services
- [ ] Run migrations
- [ ] Access backend (http://localhost:3000/health)
- [ ] Access frontend (http://localhost:3001)
- [ ] Login with default credentials
- [ ] Run tests
- [ ] Read [System Architecture](../02-architecture/system-architecture.md)
- [ ] Read [Backend Guidelines](../03-implementation/backend-guidelines.md)
- [ ] Pick first task from [Tasks](../06-tasks/README.md)
---
**Welcome aboard! 🎉**
**Version:** 1.5.0
**Last Updated:** 2025-12-01

View File

@@ -1,58 +1,487 @@
# 📋 Architecture Specification v1.5.0
## Status: first-draft
**Date:** 2025-11-30
> **สถาปัตยกรรมระบบ LCBP3-DMS**
>
> เอกสารชุดนี้อธิบายสถาปัตยกรรมทางเทคนิคของระบบ Document Management System สำหรับโครงการท่าเรือแหลมฉบังระยะที่ 3
---
## 📑 Table of Contents
## 📊 Document Status
1. [General Philosophy](./01-general-philosophy.md)
2. [TypeScript](./02-typescript.md)
3. [Functional Requirements](./03-functional-requirements.md)
- [3.1 Project & Organization Management](./03.1-project-management.md)
- [3.2 Correspondence Management](./03.2-correspondence.md)
- [3.3 RFA Management](./03.3-rfa.md)
- [3.4 Contract Drawing Management](./03.4-contract-drawing.md)
- [3.5 Shop Drawing Management](./03.5-shop-drawing.md)
- [3.6 Unified Workflow](./03.6-unified-workflow.md)
- [3.7 Transmittals Management](./03.7-transmittals.md)
- [3.8 Circulation Sheet Management](./03.8-circulation-sheet.md)
- [3.9 Revisions Management](./03.9-revisions.md)
- [3.10 File Handling](./03.10-file-handling.md)
- [3.11 Document Numbering](./03.11-document-numbering.md)
- [3.12 JSON Details](./03.12-json-details.md)
4. [Access Control & RBAC](./04-access-control.md)
5. [UI/UX Requirements](./05-ui-ux.md)
6. [Non-Functional Requirements](./06-non-functional.md)
7. [Testing Requirements](./07-testing.md)
| Attribute | Value |
| ------------------ | -------------------------------- |
| **Version** | 1.5.0 |
| **Status** | First Draft |
| **Last Updated** | 2025-11-30 |
| **Owner** | Nattanin Peancharoen |
| **Classification** | Internal Technical Documentation |
---
## 🔄 Recent Changes
## 📚 Table of Contents
See [CHANGELOG.md](../../CHANGELOG.md) for detailed version history.
### v1.4.5 (2025-11-30)
- ✅ Added comprehensive security requirements
- ✅ Enhanced resilience patterns
- ✅ Added performance targets
- ⚠️ **Breaking:** Changed document numbering from stored procedure to app-level locking
- [ภาพรวม](#-ภาพรวม-overview)
- [เอกสารสถาปัตยกรรม](#-เอกสารสถาปัตยกรรม)
- [หลักการออกแบบ](#-หลักการออกแบบ-architecture-principles)
- [Technology Stack](#-technology-stack)
- [Key Architectural Decisions](#-key-architectural-decisions)
- [Related Documents](#-related-documents)
---
## 📊 Compliance Matrix
## 🎯 ภาพรวม (Overview)
| Requirement | Status | Owner | Target Release |
| ----------------------------- | ----------- | ------------ | -------------- |
| FR-001: Correspondence CRUD | ✅ Done | Backend Team | v1.0 |
| FR-002: RFA Workflow | In Progress | Backend Team | v1.1 |
| NFR-001: API Response < 200ms | Planned | DevOps | v1.2 |
ระบบ LCBP3-DMS ใช้สถาปัตยกรรมแบบ **Headless/API-First** ที่แยก Frontend และ Backend เป็นอิสระ โดยเน้นที่:
### Core Principles
1. **Data Integrity First** - ความถูกต้องของข้อมูลเป็นสิ่งสำคัญที่สุด
2. **Security by Design** - ความปลอดภัยที่ทุกชั้น (Defense in Depth)
3. **Scalability** - รองรับการเติบโตในอนาคต
4. **Resilience** - ทนทานต่อความล้มเหลวและกู้คืนได้รวดเร็ว
5. **Observability** - ติดตามและวิเคราะห์สถานะระบบได้ง่าย
### Architecture Style
```
┌─────────────────────────────────────────┐
│ Headless/API-First Architecture │
├─────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Next.js │ ◄─────► │ NestJS │ │
│ │ Frontend │ API │ Backend │ │
│ └──────────┘ └────┬─────┘ │
│ │ │
│ ┌────▼─────┐ │
│ │ MariaDB │ │
│ │ + Redis │ │
│ └──────────┘ │
└─────────────────────────────────────────┘
```
---
## 📬 Feedback
## 📖 เอกสารสถาปัตยกรรม
Found issues? [Open an issue](https://github.com/your-org/lcbp3-dms/issues/new?template=spec-issue.md)
### 1. [System Architecture](./system-architecture.md)
**สถาปัตยกรรมระบบโดยรวม**
- Infrastructure & Deployment (QNAP Server)
- Network Architecture
- Core Services (Frontend, Backend, Database, Cache, Search)
- Backend Module Architecture (13 modules)
- Data Flow Architecture
- Security Architecture (6 layers)
- Performance & Scalability
- Resilience & Error Handling
- Monitoring & Observability
**Key Topics:**
- ✅ Modular Design (Domain-Driven)
- ✅ Two-Phase File Storage
- ✅ Document Numbering (Double-Lock Mechanism)
- ✅ Unified Workflow Engine
- ✅ 4-Level RBAC
- ✅ Caching Strategy
- ✅ Rate Limiting
### 2. [API Design](./api-design.md)
**การออกแบบ API แบบ RESTful**
- API Design Principles (API-First Approach)
- Authentication & Authorization (JWT + RBAC)
- API Conventions (HTTP Methods, Status Codes)
- Idempotency Implementation
- Pagination, Filtering & Sorting
- Security Features (Rate Limiting, Input Validation)
- Core Module APIs (Correspondence, RFA, Drawing, etc.)
- Performance Optimization
- API Versioning Strategy
**Key Topics:**
- ✅ RESTful Best Practices
- ✅ Idempotency-Key Header
- ✅ Consistent Response Format
- ✅ Comprehensive Error Handling
- ✅ Rate Limiting per Role
- ✅ File Upload Security
### 3. [Data Model](./data-model.md)
**โครงสร้างฐานข้อมูลและ Entity Relationships**
> [!NOTE]
> เอกสารนี้อยู่ระหว่างการพัฒนา กรุณาอ้างอิง [Data Dictionary](../../docs/4_Data_Dictionary_V1_4_5.md) สำหรับข้อมูลละเอียด
**Expected Content:**
- Entity Relationship Diagrams (ERD)
- Database Schema Design
- Table Relationships
- Indexing Strategy
- JSON Schema Management
- Virtual Columns for Performance
- Partitioning Strategy
---
## 🏗️ หลักการออกแบบ (Architecture Principles)
### 1. Separation of Concerns
```
Frontend (Next.js) Backend (NestJS) Database (MariaDB)
│ │ │
├─ UI/UX ├─ Business Logic ├─ Data Storage
├─ Client State ├─ API Endpoints ├─ Transactions
├─ Validation ├─ Authorization ├─ Constraints
└─ User Interaction └─ Data Processing └─ Relationships
```
### 2. Modular Architecture
**Backend Modules (Domain-Driven):**
```
Core Modules:
├── CommonModule (Shared Services)
├── AuthModule (JWT & Guards)
└── UserModule (User Management)
Business Modules:
├── ProjectModule (Projects & Contracts)
├── CorrespondenceModule (Correspondences)
├── RfaModule (RFA Management)
├── DrawingModule (Shop & Contract Drawings)
├── CirculationModule (Circulation Sheets)
└── TransmittalModule (Transmittals)
Supporting Modules:
├── WorkflowEngineModule (Unified Workflow)
├── DocumentNumberingModule (Auto Numbering)
├── SearchModule (Elasticsearch)
├── MasterModule (Master Data)
└── JsonSchemaModule (JSON Validation)
```
### 3. Security Layers
```
Layer 1: Network Security (SSL/TLS, Firewall)
Layer 2: Application Security (Rate Limiting, CSRF, XSS)
Layer 3: Authentication (JWT Tokens)
Layer 4: Authorization (4-Level RBAC)
Layer 5: Data Security (Encryption, Audit Logs)
Layer 6: File Security (Virus Scanning, Access Control)
```
### 4. Data Integrity Mechanisms
- **Two-Phase File Storage** - ป้องกัน Orphan Files
- **Double-Lock Document Numbering** - ป้องกัน Race Condition
- **Optimistic Locking** - Version Column สำหรับ Concurrent Updates
- **Transaction Management** - ACID Compliance
- **Idempotency** - ป้องกันการทำรายการซ้ำ
---
## 🛠️ Technology Stack
### Frontend Stack
| Component | Technology | Purpose |
| -------------------- | -------------------------------- | ---------------------------- |
| **Framework** | Next.js 14+ (App Router) | React Framework with SSR |
| **Language** | TypeScript (ESM) | Type-safe JavaScript |
| **Styling** | Tailwind CSS + PostCSS | Utility-first CSS |
| **Components** | shadcn/ui | Accessible Component Library |
| **State Management** | TanStack Query + React Hook Form | Server State + Form State |
| **Validation** | Zod | Schema Validation |
| **Testing** | Vitest + Playwright | Unit + E2E Testing |
### Backend Stack
| Component | Technology | Purpose |
| ------------------ | ---------------- | ---------------------------- |
| **Framework** | NestJS (Node.js) | Enterprise Node.js Framework |
| **Language** | TypeScript (ESM) | Type-safe JavaScript |
| **ORM** | TypeORM | Object-Relational Mapping |
| **Authentication** | JWT + Passport | Token-based Auth |
| **Authorization** | CASL | Permission Management |
| **Validation** | class-validator | DTO Validation |
| **Queue** | BullMQ (Redis) | Background Jobs |
| **Documentation** | Swagger/OpenAPI | API Documentation |
| **Testing** | Jest + Supertest | Unit + Integration Testing |
### Infrastructure Stack
| Component | Technology | Purpose |
| -------------------- | ----------------------- | ----------------------- |
| **Server** | QNAP TS-473A | Physical Server |
| **Containerization** | Docker + Docker Compose | Container Orchestration |
| **Reverse Proxy** | Nginx Proxy Manager | SSL/TLS + Routing |
| **Database** | MariaDB 10.11 | Relational Database |
| **Cache** | Redis 7.x | Caching + Locking |
| **Search** | Elasticsearch | Full-text Search |
| **Version Control** | Gitea | Self-hosted Git |
| **Workflow** | n8n | Workflow Automation |
---
## 🎯 Key Architectural Decisions
### ADR-001: Unified Workflow Engine
**Decision:** ใช้ Workflow Engine กลางเดียวสำหรับทุกประเภทเอกสาร
**Rationale:**
- ลดความซ้ำซ้อนของ Code
- ง่ายต่อการบำรุงรักษา
- รองรับการเปลี่ยนแปลง Workflow ได้ยืดหยุ่น
**Implementation:**
- DSL-Based Configuration (JSON)
- Workflow Versioning
- Polymorphic Entity Relationships
**Related:** [specs/05-decisions/001-workflow-engine.md](../05-decisions/001-workflow-engine.md)
### ADR-002: Two-Phase File Storage
**Decision:** แยกการอัปโหลดไฟล์เป็น 2 ขั้นตอน (Upload → Commit)
**Rationale:**
- ป้องกัน Orphan Files
- รักษา Data Integrity
- รองรับ Transaction Rollback
**Implementation:**
1. Phase 1: Upload to `temp/` → Return `temp_id`
2. Phase 2: Commit to `permanent/` when operation succeeds
3. Cleanup: Cron Job ลบไฟล์ค้างใน `temp/` > 24h
**Related:** [specs/05-decisions/002-file-storage.md](../05-decisions/002-file-storage.md)
### ADR-003: Document Numbering Strategy
**Decision:** ใช้ Application-Level Locking แทน Database Stored Procedure
**Rationale:**
- ยืดหยุ่นกว่า (Token-Based Generator)
- ง่ายต่อการ Debug
- รองรับ Complex Numbering Rules
**Implementation:**
- Layer 1: Redis Distributed Lock
- Layer 2: Optimistic Database Lock (`@VersionColumn()`)
- Retry with Exponential Backoff
### ADR-004: 4-Level RBAC
**Decision:** ใช้ Permission Hierarchy 4 ระดับ (Global, Organization, Project, Contract)
**Rationale:**
- รองรับโครงสร้างองค์กรที่ซับซ้อน
- ยืดหยุ่นในการกำหนดสิทธิ์
- Most Permissive Rule (ใช้สิทธิ์สูงสุดที่มี)
**Implementation:**
- CASL for Permission Rules
- Redis Cache for Performance
- Permission Checking at Guard Level
---
## 📊 Architecture Diagrams
### High-Level System Architecture
```mermaid
graph TB
subgraph "Client Layer"
Browser[Web Browser]
Mobile[Mobile Browser]
end
subgraph "Presentation Layer"
NPM[Nginx Proxy Manager<br/>SSL/TLS Termination]
end
subgraph "Application Layer"
Frontend[Next.js Frontend<br/>lcbp3.np-dms.work]
Backend[NestJS Backend<br/>backend.np-dms.work]
end
subgraph "Data Layer"
MariaDB[(MariaDB 10.11<br/>Primary Database)]
Redis[(Redis<br/>Cache + Queue)]
Elastic[Elasticsearch<br/>Search Engine]
Storage[File Storage<br/>/share/dms-data]
end
subgraph "Integration Layer"
N8N[n8n Workflow]
Email[Email Service]
Line[LINE Notify]
end
Browser --> NPM
Mobile --> NPM
NPM --> Frontend
NPM --> Backend
Frontend --> Backend
Backend --> MariaDB
Backend --> Redis
Backend --> Elastic
Backend --> Storage
Backend --> N8N
N8N --> Email
N8N --> Line
```
### Request Flow (Simplified)
```mermaid
sequenceDiagram
participant C as Client
participant N as Nginx
participant B as Backend
participant R as Redis
participant D as Database
C->>N: HTTPS Request + JWT
N->>B: Forward Request
B->>B: Rate Limit Check
B->>B: Input Validation
B->>B: JWT Verification
B->>R: Get Permissions
R-->>B: Permission Data
B->>B: RBAC Check
B->>D: Query/Update
D-->>B: Result
B->>D: Audit Log
B-->>C: JSON Response
```
---
## 🔗 Related Documents
### Requirements
- [Application Requirements](../../docs/0_Requirements_V1_4_5.md)
- [Full Stack Guidelines](../../docs/1_FullStackJS_V1_4_5.md)
### Implementation Plans
- [Backend Development Plan](../../docs/2_Backend_Plan_V1_4_5.md)
- [Frontend Development Plan](../../docs/3_Frontend_Plan_V1_4_5.md)
### Data Specifications
- [Data Dictionary](../../docs/4_Data_Dictionary_V1_4_5.md)
- [Database Schema SQL](../../docs/8_lcbp3_v1_4_5.sql)
### Other Specifications
- [Requirements Specs](../01-requirements/README.md)
- [Implementation Specs](../03-implementation/README.md)
- [Operations Specs](../04-operations/README.md)
- [Architecture Decisions](../05-decisions/README.md)
---
## 📈 Performance Targets
| Metric | Target | Measurement |
| ---------------------- | ------- | ----------------------------- |
| **API Response Time** | < 200ms | 90th percentile (Simple CRUD) |
| **Search Performance** | < 500ms | Complex Search Queries |
| **File Upload** | < 30s | 50MB file processing |
| **Concurrent Users** | 100+ | Simultaneous active users |
| **Cache Hit Ratio** | > 80% | Master Data caching |
| **Uptime** | 99.5% | Monthly availability |
---
## 🛡️ Security Standards
### OWASP Top 10 Protection
| Vulnerability | Protection Measure |
| ------------------------- | ------------------------------------ |
| SQL Injection | Parameterized Queries (TypeORM) |
| XSS | Input Sanitization + Output Encoding |
| CSRF | CSRF Tokens for State-Changing Ops |
| Broken Authentication | JWT + Secure Token Management |
| Security Misconfiguration | Security Headers (Helmet.js) |
| Sensitive Data Exposure | Encryption + Secure Storage |
| Insufficient Logging | Comprehensive Audit Logs |
### Rate Limiting
| User Role | Limit | Scope |
| ---------------- | ------------- | ---------- |
| Anonymous | 100 req/hour | IP Address |
| Viewer | 500 req/hour | User ID |
| Editor | 1000 req/hour | User ID |
| Document Control | 2000 req/hour | User ID |
| Admin/Superadmin | 5000 req/hour | User ID |
---
## 🔄 Change History
| Version | Date | Author | Changes |
| ------- | ---------- | ----------- | ---------------------------------- |
| 1.5.0 | 2025-11-30 | Nattanin P. | Initial architecture specification |
| 1.4.5 | 2025-11-29 | - | Added security requirements |
| 1.4.4 | 2025-11-28 | - | Enhanced resilience patterns |
---
## 📞 Questions & Feedback
### Architecture Review
- **Tech Lead:** [ระบุชื่อ]
- **Senior Architect:** [ระบุชื่อ]
### Contributing
กรุณาอ่าน [CONTRIBUTING.md](../../CONTRIBUTING.md) สำหรับแนวทางการมีส่วนร่วมในการพัฒนา Specifications
### Issues & Discussions
- [Gitea Issues](https://git.np-dms.work/lcbp3/lcbp3-dms/issues)
- [Architecture Discussions](https://git.np-dms.work/lcbp3/lcbp3-dms/discussions)
---
<div align="center">
**LCBP3-DMS Architecture Specification v1.5.0**
[System Architecture](./system-architecture.md) • [API Design](./api-design.md) • [Data Model](./data-model.md)
[Main README](../../README.md) • [Requirements](../01-requirements/README.md) • [Implementation](../03-implementation/README.md)
</div>

View File

@@ -0,0 +1,628 @@
# Data Model Architecture
---
title: 'Data Model Architecture'
version: 1.5.0
status: first-draft
owner: Nattanin Peancharoen
last_updated: 2025-11-30
related:
- specs/01-requirements/02-architecture.md
- specs/01-requirements/03-functional-requirements.md
- docs/4_Data_Dictionary_V1_4_5.md
- docs/8_lcbp3_v1_4_5.sql
---
## 📋 Overview
เอกสารนี้อธิบายสถาปัตยกรรมของ Data Model สำหรับระบบ LCBP3-DMS โดยครอบคลุมโครงสร้างฐานข้อมูล, ความสัมพันธ์ระหว่างตาราง, และหลักการออกแบบที่สำคัญ
## 🎯 Design Principles
### 1. Separation of Concerns
- **Master-Revision Pattern**: แยกข้อมูลที่ไม่เปลี่ยนแปลง (Master) จากข้อมูลที่มีการแก้ไข (Revisions)
- `correspondences` (Master) ↔ `correspondence_revisions` (Revisions)
- `rfas` (Master) ↔ `rfa_revisions` (Revisions)
- `shop_drawings` (Master) ↔ `shop_drawing_revisions` (Revisions)
### 2. Data Integrity
- **Foreign Key Constraints**: ใช้ FK ทุกความสัมพันธ์เพื่อรักษาความสมบูรณ์ของข้อมูล
- **Soft Delete**: ใช้ `deleted_at` แทนการลบข้อมูลจริง เพื่อรักษาประวัติ
- **Optimistic Locking**: ใช้ `version` column ใน `document_number_counters` ป้องกัน Race Condition
### 3. Flexibility & Extensibility
- **JSON Details Field**: เก็บข้อมูลเฉพาะประเภทใน `correspondence_revisions.details`
- **Virtual Columns**: สร้าง Index จาก JSON fields สำหรับ Performance
- **Master Data Tables**: แยกข้อมูล Master (Types, Status, Codes) เพื่อความยืดหยุ่น
### 4. Security & Audit
- **RBAC (Role-Based Access Control)**: ระบบสิทธิ์แบบ Hierarchical Scope
- **Audit Trail**: บันทึกผู้สร้าง/แก้ไข และเวลาในทุกตาราง
- **Two-Phase File Upload**: ป้องกันไฟล์ขยะด้วย Temporary Storage
## 🗂️ Database Schema Overview
### Entity Relationship Diagram
```mermaid
erDiagram
%% Core Entities
organizations ||--o{ users : "employs"
projects ||--o{ contracts : "contains"
projects ||--o{ correspondences : "manages"
%% RBAC
users ||--o{ user_assignments : "has"
roles ||--o{ user_assignments : "assigned_to"
roles ||--o{ role_permissions : "has"
permissions ||--o{ role_permissions : "granted_by"
%% Correspondences
correspondences ||--o{ correspondence_revisions : "has_revisions"
correspondence_types ||--o{ correspondences : "categorizes"
correspondence_status ||--o{ correspondence_revisions : "defines_state"
disciplines ||--o{ correspondences : "classifies"
%% RFAs
rfas ||--o{ rfa_revisions : "has_revisions"
rfa_types ||--o{ rfas : "categorizes"
rfa_status_codes ||--o{ rfa_revisions : "defines_state"
rfa_approve_codes ||--o{ rfa_revisions : "defines_result"
disciplines ||--o{ rfas : "classifies"
%% Drawings
shop_drawings ||--o{ shop_drawing_revisions : "has_revisions"
shop_drawing_main_categories ||--o{ shop_drawings : "categorizes"
shop_drawing_sub_categories ||--o{ shop_drawings : "sub_categorizes"
%% Attachments
attachments ||--o{ correspondence_attachments : "attached_to"
correspondences ||--o{ correspondence_attachments : "has"
```
## 📊 Data Model Categories
### 1. 🏢 Core & Master Data
#### 1.1 Organizations & Projects
**Tables:**
- `organization_roles` - บทบาทขององค์กร (OWNER, DESIGNER, CONSULTANT, CONTRACTOR)
- `organizations` - องค์กรทั้งหมดในระบบ
- `projects` - โครงการ
- `contracts` - สัญญาภายใต้โครงการ
- `project_organizations` - M:N ระหว่าง Projects และ Organizations
- `contract_organizations` - M:N ระหว่าง Contracts และ Organizations พร้อม Role
**Key Relationships:**
```
projects (1) ──→ (N) contracts
projects (N) ←→ (N) organizations [via project_organizations]
contracts (N) ←→ (N) organizations [via contract_organizations]
```
**Business Rules:**
- Organization code ต้องไม่ซ้ำกันในระบบ
- Contract ต้องผูกกับ Project เสมอ (ON DELETE CASCADE)
- Soft delete ใช้ `is_active` flag
---
### 2. 👥 Users & RBAC
#### 2.1 User Management
**Tables:**
- `users` - ผู้ใช้งานระบบ
- `roles` - บทบาทพร้อม Scope (Global, Organization, Project, Contract)
- `permissions` - สิทธิ์การใช้งาน (49 permissions)
- `role_permissions` - M:N mapping
- `user_assignments` - การมอบหมายบทบาทพร้อม Scope Context
**Scope Hierarchy:**
```
Global (ทั้งระบบ)
Organization (ระดับองค์กร)
Project (ระดับโครงการ)
Contract (ระดับสัญญา)
```
**Key Features:**
- **Hierarchical Scope**: User สามารถมีหลาย Role ในหลาย Scope
- **Scope Inheritance**: สิทธิ์ระดับบนครอบคลุมระดับล่าง
- **Account Security**: Failed login tracking, Account locking, Password hashing (bcrypt)
**Example User Assignment:**
```sql
-- User A เป็น Editor ในองค์กร TEAM
INSERT INTO user_assignments (user_id, role_id, organization_id)
VALUES (1, 4, 3);
-- User B เป็น Project Manager ในโครงการ LCBP3
INSERT INTO user_assignments (user_id, role_id, project_id)
VALUES (2, 6, 1);
```
---
### 3. ✉️ Correspondences (เอกสารโต้ตอบ)
#### 3.1 Master-Revision Pattern
**Master Table: `correspondences`**
เก็บข้อมูลที่ไม่เปลี่ยนแปลง:
- `correspondence_number` - เลขที่เอกสาร (Unique per Project)
- `correspondence_type_id` - ประเภทเอกสาร (RFA, RFI, TRANSMITTAL, etc.)
- `discipline_id` - สาขางาน (GEN, STR, ARC, etc.) [NEW v1.4.5]
- `project_id`, `originator_id` - โครงการและองค์กรผู้ส่ง
**Revision Table: `correspondence_revisions`**
เก็บข้อมูลที่เปลี่ยนแปลงได้:
- `revision_number` - หมายเลข Revision (0, 1, 2...)
- `is_current` - Flag สำหรับ Revision ปัจจุบัน (UNIQUE constraint)
- `title`, `description` - เนื้อหาเอกสาร
- `correspondence_status_id` - สถานะ (DRAFT, SUBOWN, REPCSC, etc.)
- `details` - JSON field สำหรับข้อมูลเฉพาะประเภท
- Virtual Columns: `v_ref_project_id`, `v_ref_type`, `v_doc_subtype` (Indexed)
**Supporting Tables:**
- `correspondence_types` - Master ประเภทเอกสาร (10 types)
- `correspondence_status` - Master สถานะ (23 status codes)
- `correspondence_sub_types` - ประเภทย่อยสำหรับ Document Numbering [NEW v1.4.5]
- `disciplines` - สาขางาน (GEN, STR, ARC, etc.) [NEW v1.4.5]
- `correspondence_recipients` - M:N ผู้รับ (TO/CC)
- `correspondence_tags` - M:N Tags
- `correspondence_references` - M:N Cross-references
**Example Query - Get Current Revision:**
```sql
SELECT c.correspondence_number, cr.title, cr.revision_label, cs.status_name
FROM correspondences c
JOIN correspondence_revisions cr ON c.id = cr.correspondence_id
JOIN correspondence_status cs ON cr.correspondence_status_id = cs.id
WHERE cr.is_current = TRUE
AND c.deleted_at IS NULL;
```
---
### 4. 📐 RFAs (Request for Approval)
#### 4.1 RFA Structure
**Master Table: `rfas`**
- `rfa_type_id` - ประเภท RFA (DWG, DOC, MAT, SPC, etc.)
- `discipline_id` - สาขางาน [NEW v1.4.5]
**Revision Table: `rfa_revisions`**
- `correspondence_id` - Link กับ Correspondence (RFA เป็น Correspondence ประเภทหนึ่ง)
- `rfa_status_code_id` - สถานะ (DFT, FAP, FRE, FCO, ASB, OBS, CC)
- `rfa_approve_code_id` - ผลการอนุมัติ (1A, 1C, 1N, 1R, 3C, 3R, 4X, 5N)
- `approved_date` - วันที่อนุมัติ
**Supporting Tables:**
- `rfa_types` - 11 ประเภท (Shop Drawing, Document, Material, etc.)
- `rfa_status_codes` - 7 สถานะ
- `rfa_approve_codes` - 8 รหัสผลการอนุมัติ
- `rfa_items` - M:N เชื่อม RFA (ประเภท DWG) กับ Shop Drawing Revisions
**RFA Workflow States:**
```
DFT (Draft)
FAP (For Approve) / FRE (For Review)
[Approval Process]
FCO (For Construction) / ASB (As-Built) / 3R (Revise) / 4X (Reject)
```
---
### 5. 📐 Drawings (แบบก่อสร้าง)
#### 5.1 Contract Drawings (แบบคู่สัญญา)
**Tables:**
- `contract_drawing_volumes` - เล่มแบบ
- `contract_drawing_cats` - หมวดหมู่หลัก
- `contract_drawing_sub_cats` - หมวดหมู่ย่อย
- `contract_drawing_subcat_cat_maps` - M:N Mapping
- `contract_drawings` - แบบคู่สัญญา
**Hierarchy:**
```
Volume (เล่ม)
└─ Category (หมวดหมู่หลัก)
└─ Sub-Category (หมวดหมู่ย่อย)
└─ Drawing (แบบ)
```
#### 5.2 Shop Drawings (แบบก่อสร้าง)
**Tables:**
- `shop_drawing_main_categories` - หมวดหมู่หลัก (ARCH, STR, MEP, etc.)
- `shop_drawing_sub_categories` - หมวดหมู่ย่อย
- `shop_drawings` - Master แบบก่อสร้าง
- `shop_drawing_revisions` - Revisions
- `shop_drawing_revision_contract_refs` - M:N อ้างอิงแบบคู่สัญญา
**Revision Tracking:**
```sql
-- Get latest revision of a shop drawing
SELECT sd.drawing_number, sdr.revision_label, sdr.revision_date
FROM shop_drawings sd
JOIN shop_drawing_revisions sdr ON sd.id = sdr.shop_drawing_id
WHERE sd.drawing_number = 'SD-STR-001'
ORDER BY sdr.revision_number DESC
LIMIT 1;
```
---
### 6. 🔄 Circulations & Transmittals
#### 6.1 Circulations (ใบเวียนภายใน)
**Tables:**
- `circulation_status_codes` - สถานะ (OPEN, IN_REVIEW, COMPLETED, CANCELLED)
- `circulations` - ใบเวียน (1:1 กับ Correspondence)
**Workflow:**
```
OPEN → IN_REVIEW → COMPLETED
CANCELLED
```
#### 6.2 Transmittals (เอกสารนำส่ง)
**Tables:**
- `transmittals` - ข้อมูล Transmittal (1:1 กับ Correspondence)
- `transmittal_items` - M:N รายการเอกสารที่นำส่ง
**Purpose Types:**
- FOR_APPROVAL
- FOR_INFORMATION
- FOR_REVIEW
- OTHER
---
### 7. 📎 File Management
#### 7.1 Two-Phase Storage Pattern
**Table: `attachments`**
**Phase 1: Temporary Upload**
```sql
INSERT INTO attachments (
original_filename, stored_filename, file_path,
mime_type, file_size, is_temporary, temp_id,
uploaded_by_user_id, expires_at, checksum
)
VALUES (
'document.pdf', 'uuid-document.pdf', '/temp/uuid-document.pdf',
'application/pdf', 1024000, TRUE, 'temp-uuid-123',
1, NOW() + INTERVAL 1 HOUR, 'sha256-hash'
);
```
**Phase 2: Commit to Permanent**
```sql
-- Update attachment to permanent
UPDATE attachments
SET is_temporary = FALSE, expires_at = NULL
WHERE temp_id = 'temp-uuid-123';
-- Link to correspondence
INSERT INTO correspondence_attachments (correspondence_id, attachment_id, is_main_document)
VALUES (1, 123, TRUE);
```
**Junction Tables:**
- `correspondence_attachments` - M:N
- `circulation_attachments` - M:N
- `shop_drawing_revision_attachments` - M:N (with file_type)
- `contract_drawing_attachments` - M:N (with file_type)
**Security Features:**
- Checksum validation (SHA-256)
- Automatic cleanup of expired temporary files
- File type validation via `mime_type`
---
### 8. 🔢 Document Numbering
#### 8.1 Format & Counter System
**Tables:**
- `document_number_formats` - Template รูปแบบเลขที่เอกสาร
- `document_number_counters` - Running Number Counter with Optimistic Locking
**Format Template Example:**
```
{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}
→ TEAM-RFA-STR-2025-0001
```
**Counter Table Structure:**
```sql
CREATE TABLE document_number_counters (
project_id INT,
originator_organization_id INT,
correspondence_type_id INT,
discipline_id INT DEFAULT 0, -- NEW v1.4.5
current_year INT,
version INT DEFAULT 0, -- Optimistic Lock
last_number INT DEFAULT 0,
PRIMARY KEY (project_id, originator_organization_id, correspondence_type_id, discipline_id, current_year)
);
```
**Optimistic Locking Pattern:**
```sql
-- Get next number with version check
UPDATE document_number_counters
SET last_number = last_number + 1,
version = version + 1
WHERE project_id = 1
AND originator_organization_id = 3
AND correspondence_type_id = 1
AND discipline_id = 2
AND current_year = 2025
AND version = @current_version; -- Optimistic lock check
-- If affected rows = 0, retry (conflict detected)
```
---
## 🔐 Security & Audit
### 1. Audit Logging
**Table: `audit_logs`**
บันทึกการเปลี่ยนแปลงสำคัญ:
- User actions (CREATE, UPDATE, DELETE)
- Entity type และ Entity ID
- Old/New values (JSON)
- IP Address, User Agent
### 2. User Preferences
**Table: `user_preferences`**
เก็บการตั้งค่าส่วนตัว:
- Language preference
- Notification settings
- UI preferences (JSON)
### 3. JSON Schema Validation
**Table: `json_schemas`**
เก็บ Schema สำหรับ Validate JSON fields:
- `correspondence_revisions.details`
- `user_preferences.preferences`
---
## 📈 Performance Optimization
### 1. Indexing Strategy
**Primary Indexes:**
- Primary Keys (AUTO_INCREMENT)
- Foreign Keys (automatic in InnoDB)
- Unique Constraints (business keys)
**Secondary Indexes:**
```sql
-- Correspondence search
CREATE INDEX idx_corr_type_status ON correspondence_revisions(correspondence_type_id, correspondence_status_id);
CREATE INDEX idx_corr_date ON correspondence_revisions(document_date);
-- Virtual columns for JSON
CREATE INDEX idx_v_ref_project ON correspondence_revisions(v_ref_project_id);
CREATE INDEX idx_v_doc_subtype ON correspondence_revisions(v_doc_subtype);
-- User lookup
CREATE INDEX idx_user_email ON users(email);
CREATE INDEX idx_user_org ON users(primary_organization_id, is_active);
```
### 2. Virtual Columns
ใช้ Virtual Columns สำหรับ Index JSON fields:
```sql
ALTER TABLE correspondence_revisions
ADD COLUMN v_ref_project_id INT GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(details, '$.ref_project_id'))) VIRTUAL,
ADD INDEX idx_v_ref_project(v_ref_project_id);
```
### 3. Partitioning (Future)
พิจารณา Partition ตาราง `audit_logs` ตามปี:
```sql
ALTER TABLE audit_logs
PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
```
---
## 🔄 Migration Strategy
### 1. TypeORM Migrations
ใช้ TypeORM Migration สำหรับ Schema Changes:
```typescript
// File: backend/src/migrations/1234567890-AddDisciplineToCorrespondences.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDisciplineToCorrespondences1234567890
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE correspondences
ADD COLUMN discipline_id INT NULL COMMENT 'สาขางาน (ถ้ามี)'
AFTER correspondence_type_id
`);
await queryRunner.query(`
ALTER TABLE correspondences
ADD CONSTRAINT fk_corr_discipline
FOREIGN KEY (discipline_id) REFERENCES disciplines(id)
ON DELETE SET NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE correspondences DROP FOREIGN KEY fk_corr_discipline`
);
await queryRunner.query(
`ALTER TABLE correspondences DROP COLUMN discipline_id`
);
}
}
```
### 2. Data Seeding
ใช้ Seed Scripts สำหรับ Master Data:
```typescript
// File: backend/src/seeds/1-organizations.seed.ts
export class OrganizationSeeder implements Seeder {
public async run(dataSource: DataSource): Promise<void> {
const repository = dataSource.getRepository(Organization);
await repository.save([
{
organization_code: 'กทท.',
organization_name: 'Port Authority of Thailand',
},
{
organization_code: 'TEAM',
organization_name: 'TEAM Consulting Engineering',
},
// ...
]);
}
}
```
---
## 📚 Best Practices
### 1. Naming Conventions
- **Tables**: `snake_case`, plural (e.g., `correspondences`, `users`)
- **Columns**: `snake_case` (e.g., `correspondence_number`, `created_at`)
- **Foreign Keys**: `{referenced_table_singular}_id` (e.g., `project_id`, `user_id`)
- **Junction Tables**: `{table1}_{table2}` (e.g., `correspondence_tags`)
### 2. Timestamp Columns
ทุกตารางควรมี:
- `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`
- `updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`
### 3. Soft Delete
ใช้ `deleted_at DATETIME NULL` แทนการลบจริง:
```sql
-- Soft delete
UPDATE correspondences SET deleted_at = NOW() WHERE id = 1;
-- Query active records
SELECT * FROM correspondences WHERE deleted_at IS NULL;
```
### 4. JSON Field Guidelines
- ใช้สำหรับข้อมูลที่ไม่ต้อง Query บ่อย
- สร้าง Virtual Columns สำหรับ fields ที่ต้อง Index
- Validate ด้วย JSON Schema
- Document structure ใน Data Dictionary
---
## 🔗 Related Documentation
- [System Architecture](./02-architecture.md) - สถาปัตยกรรมระบบโดยรวม
- [API Design](./api-design.md) - การออกแบบ API
- [Data Dictionary v1.4.5](../../docs/4_Data_Dictionary_V1_4_5.md) - รายละเอียดตารางทั้งหมด
- [SQL Schema v1.4.5](../../docs/8_lcbp3_v1_4_5.sql) - SQL Script สำหรับสร้างฐานข้อมูล
- [Functional Requirements](../01-requirements/03-functional-requirements.md) - ความต้องการด้านฟังก์ชัน
---
## 📝 Version History
| Version | Date | Author | Changes |
| ------- | ---------- | -------------------- | ---------------------------------------------- |
| 1.5.0 | 2025-11-30 | Nattanin Peancharoen | Initial data model documentation |
| 1.4.5 | 2025-11-29 | System | Added disciplines and correspondence_sub_types |

View File

@@ -1,8 +1,8 @@
# Backend Development Guidelines
**สำหรับ:** NAP-DMS LCBP3 Backend (NestJS + TypeScript)
**เวอร์ชัน:** 1.4.5
**อัปเดต:** 2025-11-30
**เวอร์ชัน:** 1.5.0
**อัปเดต:** 2025-12-01
---
@@ -453,4 +453,4 @@ async approve(@Param('id') id: string, @CurrentUser() user: User) {
| Version | Date | Changes |
| ------- | ---------- | ---------------------------------- |
| 1.0.0 | 2025-11-30 | Initial backend guidelines created |
| 1.5.0 | 2025-12-01 | Initial backend guidelines created |

View File

@@ -1,8 +1,8 @@
# Frontend Development Guidelines
**สำหรับ:** NAP-DMS LCBP3 Frontend (Next.js + TypeScript)
**เวอร์ชัน:** 1.4.5
**อัปเดต:** 2025-11-30
**เวอร์ชัน:** 1.5.0
**อัปเดต:** 2025-12-01
---
@@ -650,4 +650,4 @@ test.describe('Correspondence Workflow', () => {
| Version | Date | Changes |
| ------- | ---------- | ----------------------------------- |
| 1.0.0 | 2025-11-30 | Initial frontend guidelines created |
| 1.5.0 | 2025-12-01 | Initial frontend guidelines created |

View File

@@ -1,8 +1,8 @@
# 📝 **Documents Management System Version 1.4.5: แนวทางการพัฒนา FullStackJS**
# 📝 **Documents Management System Version 1.5.0: แนวทางการพัฒนา FullStackJS**
**สถานะ:** FINAL GUIDELINE Rev.05
**วันที่:** 2025-11-29
**อ้างอิง:** Requirements Specification v1.4.4
**สถานะ:** first-draft
**วันที่:** 2025-12-01
**อ้างอิง:** Requirements Specification v1.5.0
**Classification:** Internal Technical Documentation
## 🧠 **1. ปรัชญาทั่วไป (General Philosophy)**
@@ -1082,14 +1082,14 @@ Views เหล่านี้ทำหน้าที่เป็นแหล
## **Document Control:**
- **Document:** FullStackJS v1.4.5
- **Version:** 1.4
- **Date:** 2025-11-29
- **Document:** FullStackJS v1.5.0
- **Version:** 1.5
- **Date:** 2025-12-01
- **Author:** NAP LCBP3-DMS & Gemini
- **Status:** FINAL-Rev.05
- **Status:** first-draft
- **Classification:** Internal Technical Documentation
- **Approved By:** Nattanin
---
`End of FullStackJS Guidelines v1.4.5`
`End of FullStackJS Guidelines v1.5.0`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
# Operations Documentation
**Project:** LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This directory contains operational documentation for deploying, maintaining, and monitoring the LCBP3-DMS system.
---
## 📚 Documentation Index
### Deployment & Infrastructure
| Document | Description | Status |
| ---------------------------------------------- | ------------------------------------------------------ | ----------- |
| [deployment-guide.md](./deployment-guide.md) | Docker deployment procedures on QNAP Container Station | ✅ Complete |
| [environment-setup.md](./environment-setup.md) | Environment variables and configuration management | ✅ Complete |
### Monitoring & Maintenance
| Document | Description | Status |
| -------------------------------------------------------- | --------------------------------------------------- | ----------- |
| [monitoring-alerting.md](./monitoring-alerting.md) | Monitoring setup, health checks, and alerting rules | ✅ Complete |
| [backup-recovery.md](./backup-recovery.md) | Backup strategies and disaster recovery procedures | ✅ Complete |
| [maintenance-procedures.md](./maintenance-procedures.md) | Routine maintenance and update procedures | ✅ Complete |
### Security & Compliance
| Document | Description | Status |
| -------------------------------------------------- | ---------------------------------------------- | ----------- |
| [security-operations.md](./security-operations.md) | Security monitoring and incident response | ✅ Complete |
| [incident-response.md](./incident-response.md) | Incident classification and response playbooks | ✅ Complete |
---
## 🚀 Quick Start for Operations Team
### Initial Setup
1. **Read Deployment Guide** - [deployment-guide.md](./deployment-guide.md)
2. **Configure Environment** - [environment-setup.md](./environment-setup.md)
3. **Setup Monitoring** - [monitoring-alerting.md](./monitoring-alerting.md)
4. **Configure Backups** - [backup-recovery.md](./backup-recovery.md)
### Daily Operations
1. Monitor system health via logs and metrics
2. Review backup status (automated daily)
3. Check for security alerts
4. Review system performance metrics
### Weekly/Monthly Tasks
- Review and update SSL certificates (90 days before expiry)
- Database optimization and cleanup
- Log rotation and archival
- Security patch review and application
---
## 🏗️ Infrastructure Overview
### QNAP Container Station Architecture
```mermaid
graph TB
subgraph "QNAP Server"
subgraph "Container Station"
NGINX[NGINX<br/>Reverse Proxy<br/>Port 80/443]
Backend[NestJS Backend<br/>Port 3000]
Frontend[Next.js Frontend<br/>Port 3001]
MariaDB[(MariaDB 10.11<br/>Port 3306)]
Redis[(Redis 7.2<br/>Port 6379)]
ES[(Elasticsearch<br/>Port 9200)]
end
Volumes[("Persistent Volumes<br/>- database<br/>- uploads<br/>- logs")]
end
Internet([Internet]) --> NGINX
NGINX --> Frontend
NGINX --> Backend
Backend --> MariaDB
Backend --> Redis
Backend --> ES
MariaDB --> Volumes
Backend --> Volumes
```
### Container Services
| Service | Container Name | Ports | Persistent Volume |
| ------------- | ------------------- | ------- | ----------------------------- |
| NGINX | lcbp3-nginx | 80, 443 | /config/nginx |
| Backend | lcbp3-backend | 3000 | /app/uploads, /app/logs |
| Frontend | lcbp3-frontend | 3001 | - |
| MariaDB | lcbp3-mariadb | 3306 | /var/lib/mysql |
| Redis | lcbp3-redis | 6379 | /data |
| Elasticsearch | lcbp3-elasticsearch | 9200 | /usr/share/elasticsearch/data |
---
## 👥 Roles & Responsibilities
### System Administrator
- Deploy and configure infrastructure
- Manage QNAP server and Container Station
- Configure networking and firewall rules
- SSL certificate management
### Database Administrator (DBA)
- Database backup and recovery
- Performance tuning and optimization
- Migration execution
- Access control management
### DevOps Engineer
- CI/CD pipeline maintenance
- Container orchestration
- Monitoring and alerting setup
- Log aggregation
### Security Officer
- Security monitoring
- Incident response coordination
- Access audit reviews
- Vulnerability management
---
## 📞 Support & Escalation
### Support Tiers
**Tier 1: User Support**
- User access issues
- Password resets
- Basic troubleshooting
**Tier 2: Technical Support**
- Application errors
- Performance issues
- Feature bugs
**Tier 3: Operations Team**
- Infrastructure failures
- Database issues
- Security incidents
### Escalation Path
1. **Minor Issues** → Tier 1/2 Support → Resolution within 24h
2. **Major Issues** → Tier 3 Operations → Resolution within 4h
3. **Critical Issues** → Immediate escalation to System Architect → Resolution within 1h
---
## 🔗 Related Documentation
- [Architecture Documentation](../02-architecture/)
- [Implementation Guidelines](../03-implementation/)
- [Architecture Decision Records](../05-decisions/)
- [Backend Development Tasks](../06-tasks/)
---
## 📝 Document Maintenance
- **Review Frequency:** Monthly
- **Owner:** Operations Team
- **Last Review:** 2025-12-01
- **Next Review:** 2026-01-01
---
**Version:** 1.5.0
**Status:** Active
**Classification:** Internal Use Only

View File

@@ -0,0 +1,374 @@
# Backup & Recovery Procedures
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This document outlines backup strategies, recovery procedures, and disaster recovery planning for LCBP3-DMS.
---
## 🎯 Backup Strategy
### Backup Schedule
| Data Type | Frequency | Retention | Method |
| ---------------------- | -------------- | --------- | ----------------------- |
| Database (Full) | Daily at 02:00 | 30 days | mysqldump + compression |
| Database (Incremental) | Every 6 hours | 7 days | Binary logs |
| File Uploads | Daily at 03:00 | 30 days | rsync to backup server |
| Configuration Files | Weekly | 90 days | Git repository |
| Elasticsearch Indexes | Weekly | 14 days | Snapshot to S3/NFS |
| Application Logs | Daily | 90 days | Rotation + archival |
### Backup Locations
**Primary Backup:** QNAP NAS `/backup/lcbp3-dms`
**Secondary Backup:** External backup server (rsync)
**Offsite Backup:** Cloud storage (optional - for critical data)
---
## 💾 Database Backup
### Automated Daily Backup Script
```bash
#!/bin/bash
# File: /scripts/backup-database.sh
# Configuration
BACKUP_DIR="/backup/lcbp3-dms/database"
DB_CONTAINER="lcbp3-mariadb"
DB_NAME="lcbp3_dms"
DB_USER="backup_user"
DB_PASS="<BACKUP_USER_PASSWORD>"
RETENTION_DAYS=30
# Create backup directory
BACKUP_FILE="$BACKUP_DIR/lcbp3_$(date +%Y%m%d_%H%M%S).sql.gz"
mkdir -p "$BACKUP_DIR"
# Perform backup
echo "Starting database backup to $BACKUP_FILE"
docker exec $DB_CONTAINER mysqldump \
--user=$DB_USER \
--password=$DB_PASS \
--single-transaction \
--routines \
--triggers \
--databases $DB_NAME \
| gzip > "$BACKUP_FILE"
# Check backup success
if [ $? -eq 0 ]; then
echo "Backup completed successfully"
# Delete old backups
find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +$RETENTION_DAYS -delete
echo "Old backups cleaned up (retention: $RETENTION_DAYS days)"
else
echo "ERROR: Backup failed!"
exit 1
fi
```
### Schedule with Cron
```bash
# Edit crontab
crontab -e
# Add backup job (runs daily at 2 AM)
0 2 * * * /scripts/backup-database.sh >> /var/log/backup-database.log 2>&1
```
### Manual Database Backup
```bash
# Backup specific database
docker exec lcbp3-mariadb mysqldump \
-u root -p \
--single-transaction \
lcbp3_dms > backup_$(date +%Y%m%d).sql
# Compress backup
gzip backup_$(date +%Y%m%d).sql
```
---
## 📂 File Uploads Backup
### Automated Rsync Backup
```bash
#!/bin/bash
# File: /scripts/backup-uploads.sh
SOURCE="/var/lib/docker/volumes/lcbp3_uploads/_data"
DEST="/backup/lcbp3-dms/uploads"
RETENTION_DAYS=30
# Create incremental backup with rsync
rsync -av --delete \
--backup --backup-dir="$DEST/backup-$(date +%Y%m%d)" \
"$SOURCE/" "$DEST/current/"
# Cleanup old backups
find "$DEST" -maxdepth 1 -type d -name "backup-*" -mtime +$RETENTION_DAYS -exec rm -rf {} \;
echo "Upload backup completed: $(date)"
```
### Schedule Uploads Backup
```bash
# Run daily at 3 AM
0 3 * * * /scripts/backup-uploads.sh >> /var/log/backup-uploads.log 2>&1
```
---
## 🔄 Database Recovery
### Full Database Restore
```bash
# Step 1: Stop backend application
docker stop lcbp3-backend
# Step 2: Restore database from backup
gunzip < backup_20241201.sql.gz | \
docker exec -i lcbp3-mariadb mysql -u root -p lcbp3_dms
# Step 3: Verify restore
docker exec lcbp3-mariadb mysql -u root -p -e "
USE lcbp3_dms;
SELECT COUNT(*) FROM users;
SELECT COUNT(*) FROM correspondences;
"
# Step 4: Restart backend
docker start lcbp3-backend
```
### Point-in-Time Recovery (Using Binary Logs)
```bash
# Step 1: Restore last full backup
gunzip < backup_20241201_020000.sql.gz | \
docker exec -i lcbp3-mariadb mysql -u root -p lcbp3_dms
# Step 2: Apply binary logs since backup
docker exec lcbp3-mariadb mysqlbinlog \
--start-datetime="2024-12-01 02:00:00" \
--stop-datetime="2024-12-01 14:30:00" \
/var/lib/mysql/mysql-bin.000001 | \
docker exec -i lcbp3-mariadb mysql -u root -p lcbp3_dms
```
---
## 📁 File Uploads Recovery
### Restore from Backup
```bash
# Stop backend to prevent file operations
docker stop lcbp3-backend
# Restore files
rsync -av \
/backup/lcbp3-dms/uploads/current/ \
/var/lib/docker/volumes/lcbp3_uploads/_data/
# Verify permissions
docker exec lcbp3-backend chown -R node:node /app/uploads
# Restart backend
docker start lcbp3-backend
```
---
## 🚨 Disaster Recovery Plan
### RTO & RPO
- **RTO (Recovery Time Objective):** 4 hours
- **RPO (Recovery Point Objective):** 24 hours (for files), 6 hours (for database)
### DR Scenarios
#### Scenario 1: Database Corruption
**Detection:** Database errors in logs, application errors
**Recovery Time:** 30 minutes
**Steps:**
1. Stop backend
2. Restore last full backup
3. Apply binary logs (if needed)
4. Verify data integrity
5. Restart services
#### Scenario 2: Complete Server Failure
**Detection:** Server unresponsive
**Recovery Time:** 4 hours
**Steps:**
1. Provision new QNAP server or VM
2. Install Docker & Container Station
3. Clone Git repository
4. Restore database backup
5. Restore file uploads
6. Deploy containers
7. Update DNS (if needed)
8. Verify functionality
#### Scenario 3: Ransomware Attack
**Detection:** Encrypted files, ransom note
**Recovery Time:** 6 hours
**Steps:**
1. **DO NOT pay ransom**
2. Isolate infected server
3. Provision clean environment
4. Restore from offsite backup
5. Scan restored backup for malware
6. Deploy and verify
7. Review security logs
8. Implement additional security measures
---
## ✅ Backup Verification
### Weekly Backup Testing
```bash
#!/bin/bash
# File: /scripts/test-backup.sh
# Create temporary test database
docker exec lcbp3-mariadb mysql -u root -p -e "
CREATE DATABASE IF NOT EXISTS test_restore;
"
# Restore latest backup to test database
LATEST_BACKUP=$(ls -t /backup/lcbp3-dms/database/*.sql.gz | head -1)
gunzip < "$LATEST_BACKUP" | \
sed 's/USE `lcbp3_dms`/USE `test_restore`/g' | \
docker exec -i lcbp3-mariadb mysql -u root -p
# Verify table counts
docker exec lcbp3-mariadb mysql -u root -p -e "
SELECT COUNT(*) FROM test_restore.users;
SELECT COUNT(*) FROM test_restore.correspondences;
"
# Cleanup
docker exec lcbp3-mariadb mysql -u root -p -e "
DROP DATABASE test_restore;
"
echo "Backup verification completed: $(date)"
```
### Monthly DR Drill
- Test full system restore on standby server
- Document time taken and issues encountered
- Update DR procedures based on findings
---
## 📊 Backup Monitoring
### Backup Status Dashboard
Monitor:
- ✅ Last successful backup timestamp
- ✅ Backup file size (detect anomalies)
- ✅ Backup success/failure rate
- ✅ Available backup storage space
### Alerts
Send alert if:
- ❌ Backup fails
- ❌ Backup file size < 50% of average (possible corruption)
- ❌ No backup in last 48 hours
- ❌ Backup storage < 20% free
---
## 🔧 Maintenance
### Optimize Backup Performance
```sql
-- Enable InnoDB compression for large tables
ALTER TABLE correspondences ROW_FORMAT=COMPRESSED;
ALTER TABLE workflow_history ROW_FORMAT=COMPRESSED;
-- Archive old audit logs
-- Move records older than 1 year to archive table
INSERT INTO audit_logs_archive
SELECT * FROM audit_logs
WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);
DELETE FROM audit_logs
WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);
```
---
## 📚 Backup Checklist
### Daily Tasks
- [ ] Verify automated backups completed
- [ ] Check backup log files for errors
- [ ] Monitor backup storage space
### Weekly Tasks
- [ ] Test restore from random backup
- [ ] Review backup size trends
- [ ] Verify offsite backups synced
### Monthly Tasks
- [ ] Full DR drill
- [ ] Review and update DR procedures
- [ ] Test backup restoration on different server
### Quarterly Tasks
- [ ] Audit backup access controls
- [ ] Review backup retention policies
- [ ] Update backup documentation
---
## 🔗 Related Documents
- [Deployment Guide](./deployment-guide.md)
- [Monitoring & Alerting](./monitoring-alerting.md)
- [Incident Response](./incident-response.md)
---
**Version:** 1.5.0
**Last Review:** 2025-12-01
**Next Review:** 2026-03-01

View File

View File

View File

@@ -0,0 +1,463 @@
# Environment Setup & Configuration
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This document describes environment variables, configuration files, and secrets management for LCBP3-DMS deployment.
---
## 🔐 Environment Variables
### Backend (.env)
```bash
# File: backend/.env (DO NOT commit to Git)
# Application
NODE_ENV=production
APP_PORT=3000
APP_URL=https://lcbp3-dms.example.com
# Database
DB_HOST=lcbp3-mariadb
DB_PORT=3306
DB_USER=lcbp3_user
DB_PASS=<STRONG_PASSWORD>
DB_NAME=lcbp3_dms
# Redis
REDIS_HOST=lcbp3-redis
REDIS_PORT=6379
REDIS_PASSWORD=<STRONG_PASSWORD>
# JWT Authentication
JWT_SECRET=<RANDOM_256_BIT_SECRET>
JWT_EXPIRATION=1h
JWT_REFRESH_SECRET=<RANDOM_256_BIT_SECRET>
JWT_REFRESH_EXPIRATION=7d
# File Storage
UPLOAD_DIR=/app/uploads
TEMP_UPLOAD_DIR=/app/uploads/temp
MAX_FILE_SIZE=104857600 # 100MB
ALLOWED_FILE_TYPES=pdf,doc,docx,xls,xlsx,dwg,jpg,png
# SMTP Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASS=<APP_PASSWORD>
SMTP_FROM="LCBP3-DMS System <noreply@example.com>"
# LINE Notify (Optional)
LINE_NOTIFY_ENABLED=true
# ClamAV Virus Scanner
CLAMAV_HOST=clamav
CLAMAV_PORT=3310
# Elasticsearch
ELASTICSEARCH_NODE=http://lcbp3-elasticsearch:9200
ELASTICSEARCH_INDEX_PREFIX=lcbp3_
# Logging
LOG_LEVEL=info
LOG_FILE_PATH=/app/logs
# Frontend URL (for email links)
FRONTEND_URL=https://lcbp3-dms.example.com
# Rate Limiting
RATE_LIMIT_TTL=60
RATE_LIMIT_MAX=100
```
### Frontend (.env.local)
```bash
# File: frontend/.env.local (DO NOT commit to Git)
# API Backend
NEXT_PUBLIC_API_URL=https://lcbp3-dms.example.com/api
# Application
NEXT_PUBLIC_APP_NAME=LCBP3-DMS
NEXT_PUBLIC_APP_VERSION=1.5.0
# Feature Flags
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_ENABLE_LINE_NOTIFY=true
```
---
## 🐳 Docker Compose Configuration
### Production docker-compose.yml
```yaml
# File: docker-compose.yml
version: '3.8'
services:
# NGINX Reverse Proxy
nginx:
image: nginx:alpine
container_name: lcbp3-nginx
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx-logs:/var/log/nginx
depends_on:
- backend
- frontend
restart: unless-stopped
networks:
- lcbp3-network
# NestJS Backend
backend:
image: lcbp3-backend:latest
container_name: lcbp3-backend
environment:
- NODE_ENV=production
env_file:
- ./backend/.env
volumes:
- uploads:/app/uploads
- backend-logs:/app/logs
depends_on:
- mariadb
- redis
- elasticsearch
restart: unless-stopped
networks:
- lcbp3-network
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 30s
timeout: 10s
retries: 3
# Next.js Frontend
frontend:
image: lcbp3-frontend:latest
container_name: lcbp3-frontend
environment:
- NODE_ENV=production
env_file:
- ./frontend/.env.local
restart: unless-stopped
networks:
- lcbp3-network
# MariaDB Database
mariadb:
image: mariadb:10.11
container_name: lcbp3-mariadb
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- mariadb-data:/var/lib/mysql
- ./mariadb/init:/docker-entrypoint-initdb.d:ro
ports:
- '3306:3306'
restart: unless-stopped
networks:
- lcbp3-network
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
# Redis Cache & Queue
redis:
image: redis:7.2-alpine
container_name: lcbp3-redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
ports:
- '6379:6379'
restart: unless-stopped
networks:
- lcbp3-network
# Elasticsearch
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
container_name: lcbp3-elasticsearch
environment:
- discovery.type=single-node
- 'ES_JAVA_OPTS=-Xms512m -Xmx512m'
- xpack.security.enabled=false
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
ports:
- '9200:9200'
restart: unless-stopped
networks:
- lcbp3-network
# ClamAV (Optional - for virus scanning)
clamav:
image: clamav/clamav:latest
container_name: lcbp3-clamav
restart: unless-stopped
networks:
- lcbp3-network
networks:
lcbp3-network:
driver: bridge
volumes:
mariadb-data:
redis-data:
elasticsearch-data:
uploads:
backend-logs:
nginx-logs:
```
### Development docker-compose.override.yml
```yaml
# File: docker-compose.override.yml (Local development only)
# Add to .gitignore
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
volumes:
- ./backend:/app
- /app/node_modules
environment:
- NODE_ENV=development
- LOG_LEVEL=debug
ports:
- '3000:3000'
- '9229:9229' # Node.js debugger
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
ports:
- '3001:3000'
mariadb:
ports:
- '3307:3306' # Avoid conflict with local MySQL
redis:
ports:
- '6380:6379'
elasticsearch:
environment:
- 'ES_JAVA_OPTS=-Xms256m -Xmx256m' # Lower memory for dev
```
---
## 🔑 Secrets Management
### Using Docker Secrets (Recommended for Production)
```yaml
# docker-compose.yml
services:
backend:
secrets:
- db_password
- jwt_secret
environment:
DB_PASS_FILE: /run/secrets/db_password
JWT_SECRET_FILE: /run/secrets/jwt_secret
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_secret:
file: ./secrets/jwt_secret.txt
```
### Generate Strong Secrets
```bash
# Generate JWT Secret
openssl rand -base64 64
# Generate Database Password
openssl rand -base64 32
# Generate Redis Password
openssl rand -base64 32
```
---
## 📁 Directory Structure
```
lcbp3/
├── backend/
│ ├── .env # Backend environment (DO NOT commit)
│ ├── .env.example # Example template (commit this)
│ └── ...
├── frontend/
│ ├── .env.local # Frontend environment (DO NOT commit)
│ ├── .env.example # Example template
│ └── ...
├── nginx/
│ ├── nginx.conf
│ └── ssl/
│ ├── cert.pem
│ └── key.pem
├── secrets/ # Docker secrets (DO NOT commit)
│ ├── db_password.txt
│ ├── jwt_secret.txt
│ └── redis_password.txt
├── docker-compose.yml # Production config
└── docker-compose.override.yml # Development config (DO NOT commit)
```
---
## ⚙️ Configuration Management
### Environment-Specific Configs
**Development:**
```bash
NODE_ENV=development
LOG_LEVEL=debug
DB_HOST=localhost
```
**Staging:**
```bash
NODE_ENV=staging
LOG_LEVEL=info
DB_HOST=staging-db.internal
```
**Production:**
```bash
NODE_ENV=production
LOG_LEVEL=warn
DB_HOST=prod-db.internal
```
### Configuration Validation
Backend validates environment variables at startup:
```typescript
// File: backend/src/config/env.validation.ts
import * as Joi from 'joi';
export const envValidationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'staging', 'production')
.required(),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(3306),
DB_USER: Joi.string().required(),
DB_PASS: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
// ...
});
```
---
## 🔒 Security Best Practices
### DO:
- ✅ Use strong, random passwords (minimum 32 characters)
- ✅ Rotate secrets every 90 days
- ✅ Use Docker secrets for production
- ✅ Add `.env` files to `.gitignore`
- ✅ Provide `.env.example` templates
- ✅ Validate environment variables at startup
### DON'T:
- ❌ Commit `.env` files to Git
- ❌ Use weak or default passwords
- ❌ Share production credentials via email/chat
- ❌ Reuse passwords across environments
- ❌ Hardcode secrets in source code
---
## 🛠️ Troubleshooting
### Common Issues
**Backend can't connect to database:**
```bash
# Check database container is running
docker ps | grep mariadb
# Check database logs
docker logs lcbp3-mariadb
# Verify credentials
docker exec lcbp3-backend env | grep DB_
```
**Redis connection refused:**
```bash
# Test Redis connection
docker exec lcbp3-redis redis-cli -a <PASSWORD> ping
# Should return: PONG
```
**Environment variable not loading:**
```bash
# Check if env file exists
ls -la backend/.env
# Check if backend loaded the env
docker exec lcbp3-backend env | grep NODE_ENV
```
---
## 📚 Related Documents
- [Deployment Guide](./deployment-guide.md)
- [Security Operations](./security-operations.md)
- [ADR-005: Technology Stack](../05-decisions/ADR-005-technology-stack.md)
---
**Version:** 1.5.0
**Last Review:** 2025-12-01
**Next Review:** 2026-03-01

View File

@@ -0,0 +1,483 @@
# Incident Response Procedures
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This document outlines incident classification, response procedures, and post-incident reviews for LCBP3-DMS.
---
## 🚨 Incident Classification
### Severity Levels
| Severity | Description | Response Time | Examples |
| ----------------- | ---------------------------- | ----------------- | ----------------------------------------------- |
| **P0 - Critical** | Complete system outage | 15 minutes | Database down, All services unavailable |
| **P1 - High** | Major functionality impaired | 1 hour | Authentication failing, Cannot create documents |
| **P2 - Medium** | Degraded performance | 4 hours | Slow response time, Some features broken |
| **P3 - Low** | Minor issues | Next business day | UI glitch, Non-critical bug |
---
## 📞 Incident Response Team
### Roles & Responsibilities
**Incident Commander (IC)**
- Coordinates response efforts
- Makes final decisions
- Communicates with stakeholders
**Technical Lead (TL)**
- Diagnoses technical issues
- Implements fixes
- Coordinates with engineers
**Communications Lead (CL)**
- Updates stakeholders
- Manages internal/external communications
- Documents incident timeline
**On-Call Engineer**
- First responder
- Initial triage and investigation
- Escalates to appropriate team
---
## 🔄 Incident Response Workflow
```mermaid
flowchart TD
Start([Incident Detected]) --> Acknowledge[Acknowledge Incident]
Acknowledge --> Assess[Assess Severity]
Assess --> P0{Severity?}
P0 -->|P0/P1| Alert[Page Incident Commander]
P0 -->|P2/P3| Assign[Assign to On-Call]
Alert --> Investigate[Investigate Root Cause]
Assign --> Investigate
Investigate --> Mitigate[Implement Mitigation]
Mitigate --> Verify[Verify Resolution]
Verify --> Resolved{Resolved?}
Resolved -->|No| Escalate[Escalate/Re-assess]
Escalate --> Investigate
Resolved -->|Yes| Communicate[Communicate Resolution]
Communicate --> PostMortem[Schedule Post-Mortem]
PostMortem --> End([Close Incident])
```
---
## 📋 Incident Response Playbooks
### P0: Database Down
**Symptoms:**
- Backend returns 500 errors
- Cannot connect to database
- Health check fails
**Immediate Actions:**
1. **Verify Issue**
```bash
docker ps | grep mariadb
docker logs lcbp3-mariadb --tail=50
```
2. **Attempt Restart**
```bash
docker restart lcbp3-mariadb
```
3. **Check Database Process**
```bash
docker exec lcbp3-mariadb ps aux | grep mysql
```
4. **If Restart Fails:**
```bash
# Check disk space
df -h
# Check database logs for corruption
docker exec lcbp3-mariadb cat /var/log/mysql/error.log
# If corrupted, restore from backup
# See backup-recovery.md
```
5. **Escalate to DBA** if not resolved in 30 minutes
---
### P0: Complete System Outage
**Symptoms:**
- All services return 502/503
- Health checks fail
- Users cannot access system
**Immediate Actions:**
1. **Check Container Status**
```bash
docker-compose ps
# Identify which containers are down
```
2. **Restart All Services**
```bash
docker-compose restart
```
3. **Check QNAP Server Resources**
```bash
top
df -h
free -h
```
4. **Check Network**
```bash
ping 8.8.8.8
netstat -tlnp
```
5. **If Server Issue:**
- Reboot QNAP server
- Contact QNAP support
---
### P1: Authentication System Failing
**Symptoms:**
- Users cannot log in
- JWT validation fails
- 401 errors
**Immediate Actions:**
1. **Check Redis (Session Store)**
```bash
docker exec lcbp3-redis redis-cli ping
# Should return PONG
```
2. **Check JWT Secret Configuration**
```bash
docker exec lcbp3-backend env | grep JWT_SECRET
# Verify not empty
```
3. **Check Backend Logs**
```bash
docker logs lcbp3-backend --tail=100 | grep "JWT\|Auth"
```
4. **Temporary Mitigation:**
```bash
# Restart backend to reload config
docker restart lcbp3-backend
```
---
### P1: File Upload Failing
**Symptoms:**
- Users cannot upload files
- 500 errors on file upload
- "Disk full" errors
**Immediate Actions:**
1. **Check Disk Space**
```bash
df -h /var/lib/docker/volumes/lcbp3_uploads
```
2. **If Disk Full:**
```bash
# Clean up temp uploads
find /var/lib/docker/volumes/lcbp3_uploads/_data/temp \
-type f -mtime +1 -delete
```
3. **Check ClamAV (Virus Scanner)**
```bash
docker logs lcbp3-clamav --tail=50
docker restart lcbp3-clamav
```
4. **Check File Permissions**
```bash
docker exec lcbp3-backend ls -la /app/uploads
```
---
### P2: Slow Performance
**Symptoms:**
- Pages load slowly
- API response time > 2s
- Users complain about slowness
**Actions:**
1. **Check System Resources**
```bash
docker stats
# Identify high CPU/memory containers
```
2. **Check Database Performance**
```sql
-- Show slow queries
SHOW PROCESSLIST;
-- Check connections
SHOW STATUS LIKE 'Threads_connected';
```
3. **Check Redis**
```bash
docker exec lcbp3-redis redis-cli --stat
```
4. **Check Application Logs**
```bash
docker logs lcbp3-backend | grep "Slow request"
```
5. **Temporary Mitigation:**
- Restart slow containers
- Clear Redis cache if needed
- Kill long-running queries
---
### P2: Email Notifications Not Sending
**Symptoms:**
- Users not receiving emails
- Email queue backing up
**Actions:**
1. **Check Email Queue**
```bash
# Access BullMQ dashboard or check Redis
docker exec lcbp3-redis redis-cli LLEN bull:email:waiting
```
2. **Check Email Processor Logs**
```bash
docker logs lcbp3-backend | grep "email\|SMTP"
```
3. **Test SMTP Connection**
```bash
docker exec lcbp3-backend node -e "
const nodemailer = require('nodemailer');
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
transport.verify().then(console.log).catch(console.error);
"
```
4. **Check SMTP Credentials**
- Verify not expired
- Check firewall/network access
---
## 📝 Incident Documentation
### Incident Report Template
```markdown
# Incident Report: [Brief Description]
**Incident ID:** INC-YYYYMMDD-001
**Severity:** P1
**Status:** Resolved
**Incident Commander:** [Name]
## Timeline
| Time | Event |
| ----- | --------------------------------------------------------- |
| 14:00 | Alert: High error rate detected |
| 14:05 | On-call engineer acknowledged |
| 14:10 | Identified root cause: Database connection pool exhausted |
| 14:15 | Implemented mitigation: Increased pool size |
| 14:20 | Verified resolution |
| 14:30 | Incident resolved |
## Impact
- **Duration:** 30 minutes
- **Affected Users:** ~50 users
- **Affected Services:** Document creation, Search
- **Data Loss:** None
## Root Cause
Database connection pool was exhausted due to slow queries not releasing connections.
## Resolution
1. Increased connection pool size from 10 to 20
2. Optimized slow queries
3. Added connection pool monitoring
## Action Items
- [ ] Add connection pool size alert (Owner: DevOps, Due: Next Sprint)
- [ ] Implement automatic query timeouts (Owner: Backend, Due: 2025-12-15)
- [ ] Review all queries for optimization (Owner: DBA, Due: 2025-12-31)
## Lessons Learned
- Connection pool monitoring was insufficient
- Need automated remediation for common issues
```
---
## 🔍 Post-Incident Review (PIR)
### PIR Meeting Agenda
1. **Timeline Review** (10 min)
- What happened and when?
- What was the impact?
2. **Root Cause Analysis** (15 min)
- Why did it happen?
- What were the contributing factors?
3. **What Went Well** (10 min)
- What did we do right?
- What helped us resolve quickly?
4. **What Went Wrong** (15 min)
- What could we have done better?
- What slowed us down?
5. **Action Items** (10 min)
- What changes will prevent this?
- Who owns each action?
- When will they be completed?
### PIR Best Practices
- **Blameless Culture:** Focus on systems, not individuals
- **Actionable Outcomes:** Every PIR should produce concrete actions
- **Follow Through:** Track action items to completion
- **Share Learnings:** Distribute PIR summary to entire team
---
## 📊 Incident Metrics
### Track & Review Monthly
- **MTTR (Mean Time To Resolution):** Average time to resolve incidents
- **MTBF (Mean Time Between Failures):** Average time between incidents
- **Incident Frequency:** Number of incidents per month
- **Severity Distribution:** Breakdown by P0/P1/P2/P3
- **Repeat Incidents:** Same root cause occurring multiple times
---
## ✅ Incident Response Checklist
### During Incident
- [ ] Acknowledge incident in tracking system
- [ ] Assess severity and assign IC
- [ ] Create incident channel (Slack/Teams)
- [ ] Begin documenting timeline
- [ ] Investigate and implement mitigation
- [ ] Communicate status updates every 30 min (P0/P1)
- [ ] Verify resolution
- [ ] Communicate resolution to stakeholders
### After Incident
- [ ] Create incident report
- [ ] Schedule PIR within 48 hours
- [ ] Identify action items
- [ ] Assign owners and deadlines
- [ ] Update runbooks/playbooks
- [ ] Share learnings with team
---
## 🔗 Related Documents
- [Monitoring & Alerting](./monitoring-alerting.md)
- [Backup & Recovery](./backup-recovery.md)
- [Security Operations](./security-operations.md)
---
**Version:** 1.5.0
**Last Review:** 2025-12-01
**Next Review:** 2026-03-01

View File

@@ -0,0 +1,501 @@
# Maintenance Procedures
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This document outlines routine maintenance tasks, update procedures, and optimization guidelines for LCBP3-DMS.
---
## 📅 Maintenance Schedule
### Daily Tasks
- Monitor system health and backups
- Review error logs
- Check disk space
### Weekly Tasks
- Database optimization
- Log rotation and cleanup
- Security patch review
- Performance monitoring review
### Monthly Tasks
- SSL certificate check
- Dependency updates (Security patches)
- Database maintenance
- Backup restoration test
### Quarterly Tasks
- Full system update
- Capacity planning review
- Security audit
- Disaster recovery drill
---
## 🔄 Update Procedures
### Application Updates
#### Backend Update
```bash
#!/bin/bash
# File: /scripts/update-backend.sh
# Step 1: Backup database
/scripts/backup-database.sh
# Step 2: Pull latest code
cd /app/lcbp3/backend
git pull origin main
# Step 3: Install dependencies
docker exec lcbp3-backend npm install
# Step 4: Run migrations
docker exec lcbp3-backend npm run migration:run
# Step 5: Build application
docker exec lcbp3-backend npm run build
# Step 6: Restart backend
docker restart lcbp3-backend
# Step 7: Verify health
sleep 10
curl -f http://localhost:3000/health || {
echo "Health check failed! Rolling back..."
docker exec lcbp3-backend npm run migration:revert
docker restart lcbp3-backend
exit 1
}
echo "Backend updated successfully"
```
#### Frontend Update
```bash
#!/bin/bash
# File: /scripts/update-frontend.sh
# Step 1: Pull latest code
cd /app/lcbp3/frontend
git pull origin main
# Step 2: Install dependencies
docker exec lcbp3-frontend npm install
# Step 3: Build application
docker exec lcbp3-frontend npm run build
# Step 4: Restart frontend
docker restart lcbp3-frontend
# Step 5: Verify
sleep 10
curl -f http://localhost:3001 || {
echo "Frontend failed to start!"
exit 1
}
echo "Frontend updated successfully"
```
### Zero-Downtime Deployment
```bash
#!/bin/bash
# File: /scripts/zero-downtime-deploy.sh
# Using blue-green deployment strategy
# Step 1: Start new "green" backend
docker-compose -f docker-compose.green.yml up -d backend
# Step 2: Wait for health check
for i in {1..30}; do
curl -f http://localhost:3002/health && break
sleep 2
done
# Step 3: Switch NGINX to green
docker exec lcbp3-nginx nginx -s reload
# Step 4: Stop old "blue" backend
docker stop lcbp3-backend-blue
echo "Deployment completed with zero downtime"
```
---
## 🗄️ Database Maintenance
### Weekly Database Optimization
```sql
-- File: /scripts/optimize-database.sql
-- Optimize tables
OPTIMIZE TABLE correspondences;
OPTIMIZE TABLE rfas;
OPTIMIZE TABLE workflow_instances;
OPTIMIZE TABLE attachments;
-- Analyze tables for query optimization
ANALYZE TABLE correspondences;
ANALYZE TABLE rfas;
-- Check for table corruption
CHECK TABLE correspondences;
CHECK TABLE rfas;
-- Rebuild indexes if fragmented
ALTER TABLE correspondences ENGINE=InnoDB;
```
```bash
#!/bin/bash
# File: /scripts/weekly-db-maintenance.sh
docker exec lcbp3-mariadb mysql -u root -p lcbp3_dms < /scripts/optimize-database.sql
echo "Database optimization completed: $(date)"
```
### Monthly Database Cleanup
```sql
-- Archive old audit logs (older than 1 year)
INSERT INTO audit_logs_archive
SELECT * FROM audit_logs
WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);
DELETE FROM audit_logs
WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);
-- Clean up deleted notifications (older than 90 days)
DELETE FROM notifications
WHERE deleted_at IS NOT NULL
AND deleted_at < DATE_SUB(NOW(), INTERVAL 90 DAY);
-- Clean up expired temp uploads (older than 24h)
DELETE FROM temp_uploads
WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 DAY);
-- Optimize after cleanup
OPTIMIZE TABLE audit_logs;
OPTIMIZE TABLE notifications;
OPTIMIZE TABLE temp_uploads;
```
---
## 📦 Dependency Updates
### Security Patch Updates (Monthly)
```bash
#!/bin/bash
# File: /scripts/update-dependencies.sh
cd /app/lcbp3/backend
# Check for security vulnerabilities
npm audit
# Update security patches only (no major versions)
npm audit fix
# Run tests
npm test
# If tests pass, commit and deploy
git add package*.json
git commit -m "chore: security patch updates"
git push origin main
```
### Major Version Updates (Quarterly)
```bash
# Check for outdated packages
npm outdated
# Update one major dependency at a time
npm install @nestjs/core@latest
# Test thoroughly
npm test
npm run test:e2e
# If successful, commit
git commit -am "chore: update @nestjs/core to vX.X.X"
```
---
## 🧹 Log Management
### Log Rotation Configuration
```bash
# File: /etc/logrotate.d/lcbp3-dms
/app/logs/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 node node
sharedscripts
postrotate
docker exec lcbp3-backend kill -USR1 1
endscript
}
```
### Manual Log Cleanup
```bash
#!/bin/bash
# File: /scripts/cleanup-logs.sh
# Delete logs older than 90 days
find /app/logs -name "*.log" -type f -mtime +90 -delete
# Compress logs older than 7 days
find /app/logs -name "*.log" -type f -mtime +7 -exec gzip {} \;
# Clean Docker logs
docker system prune -f --volumes --filter "until=720h"
echo "Log cleanup completed: $(date)"
```
---
## 🔐 SSL Certificate Renewal
### Check Certificate Expiry
```bash
#!/bin/bash
# File: /scripts/check-ssl-cert.sh
CERT_FILE="/app/nginx/ssl/cert.pem"
EXPIRY_DATE=$(openssl x509 -enddate -noout -in "$CERT_FILE" | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY_DATE" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( ($EXPIRY_EPOCH - $NOW_EPOCH) / 86400 ))
echo "SSL certificate expires in $DAYS_LEFT days"
if [ $DAYS_LEFT -lt 30 ]; then
echo "WARNING: SSL certificate expires soon!"
# Send alert
/scripts/send-alert-email.sh "SSL Certificate Expiring" "Certificate expires in $DAYS_LEFT days"
fi
```
### Renew SSL Certificate (Let's Encrypt)
```bash
#!/bin/bash
# File: /scripts/renew-ssl.sh
# Renew certificate
certbot renew --webroot -w /app/nginx/html
# Copy new certificate
cp /etc/letsencrypt/live/lcbp3-dms.example.com/fullchain.pem /app/nginx/ssl/cert.pem
cp /etc/letsencrypt/live/lcbp3-dms.example.com/privkey.pem /app/nginx/ssl/key.pem
# Reload NGINX
docker exec lcbp3-nginx nginx -s reload
echo "SSL certificate renewed: $(date)"
```
---
## 🧪 Performance Optimization
### Database Query Optimization
```sql
-- Find slow queries
SELECT * FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 10;
-- Add indexes for frequently queried columns
CREATE INDEX idx_correspondences_status ON correspondences(status);
CREATE INDEX idx_rfas_workflow_status ON rfas(workflow_status);
CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id);
-- Analyze query execution plan
EXPLAIN SELECT * FROM correspondences
WHERE status = 'PENDING'
AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY);
```
### Redis Cache Optimization
```bash
#!/bin/bash
# File: /scripts/optimize-redis.sh
# Check Redis memory usage
docker exec lcbp3-redis redis-cli INFO memory
# Set max memory policy
docker exec lcbp3-redis redis-cli CONFIG SET maxmemory 1gb
docker exec lcbp3-redis redis-cli CONFIG SET maxmemory-policy allkeys-lru
# Save configuration
docker exec lcbp3-redis redis-cli CONFIG REWRITE
# Clear stale cache (if needed)
docker exec lcbp3-redis redis-cli FLUSHDB
```
### Application Performance Tuning
```typescript
// Enable production optimizations in NestJS
// File: backend/src/main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger:
process.env.NODE_ENV === 'production'
? ['error', 'warn']
: ['log', 'error', 'warn', 'debug'],
});
// Enable compression
app.use(compression());
// Enable caching
app.useGlobalInterceptors(new CacheInterceptor());
// Set global timeout
app.use(timeout('30s'));
await app.listen(3000);
}
```
---
## 🔒 Security Maintenance
### Monthly Security Tasks
```bash
#!/bin/bash
# File: /scripts/security-maintenance.sh
# Update system packages
apt-get update && apt-get upgrade -y
# Update ClamAV virus definitions
docker exec lcbp3-clamav freshclam
# Scan for rootkits
rkhunter --check --skip-keypress
# Check for unauthorized users
awk -F: '($3 >= 1000) {print $1}' /etc/passwd
# Review sudo access
cat /etc/sudoers
# Check firewall rules
iptables -L -n -v
echo "Security maintenance completed: $(date)"
```
---
## ✅ Maintenance Checklist
### Pre-Maintenance
- [ ] Announce maintenance window to users
- [ ] Backup database and files
- [ ] Document current system state
- [ ] Prepare rollback plan
### During Maintenance
- [ ] Put system in maintenance mode (if needed)
- [ ] Perform updates/changes
- [ ] Run smoke tests
- [ ] Monitor system health
### Post-Maintenance
- [ ] Verify all services running
- [ ] Run full test suite
- [ ] Monitor performance metrics
- [ ] Communicate completion to users
- [ ] Document changes made
---
## 🔧 Emergency Maintenance
### Unplanned Maintenance Procedures
1. **Assess Urgency**
- Can it wait for scheduled maintenance?
- Is it causing active issues?
2. **Communicate Impact**
- Notify stakeholders immediately
- Estimate downtime
- Provide updates every 30 minutes
3. **Execute Carefully**
- Always backup first
- Have rollback plan ready
- Test in staging if possible
4. **Post-Maintenance Review**
- Document what happened
- Identify preventive measures
- Update runbooks
---
## 📚 Related Documents
- [Deployment Guide](./deployment-guide.md)
- [Backup & Recovery](./backup-recovery.md)
- [Monitoring & Alerting](./monitoring-alerting.md)
---
**Version:** 1.5.0
**Last Review:** 2025-12-01
**Next Review:** 2026-03-01

View File

@@ -0,0 +1,443 @@
# Monitoring & Alerting
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This document describes monitoring setup, health checks, and alerting rules for LCBP3-DMS.
---
## 🎯 Monitoring Objectives
- **Availability:** System uptime > 99.5%
- **Performance:** API response time < 500ms (P95)
- **Reliability:** Error rate < 1%
- **Capacity:** Resource utilization < 80%
---
## 📊 Key Metrics
### Application Metrics
| Metric | Target | Alert Threshold |
| ----------------------- | ------- | ------------------ |
| API Response Time (P95) | < 500ms | > 1000ms |
| Error Rate | < 1% | > 5% |
| Request Rate | N/A | Sudden ±50% change |
| Active Users | N/A | - |
| Queue Length (BullMQ) | < 100 | > 500 |
### Infrastructure Metrics
| Metric | Target | Alert Threshold |
| ------------ | ------ | ----------------- |
| CPU Usage | < 70% | > 90% |
| Memory Usage | < 80% | > 95% |
| Disk Usage | < 80% | > 90% |
| Network I/O | N/A | Anomaly detection |
### Database Metrics
| Metric | Target | Alert Threshold |
| --------------------- | ------- | --------------- |
| Query Time (P95) | < 100ms | > 500ms |
| Connection Pool Usage | < 80% | > 95% |
| Slow Queries | 0 | > 10/min |
| Replication Lag | 0s | > 30s |
---
## 🔍 Health Checks
### Backend Health Endpoint
```typescript
// File: backend/src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
TypeOrmHealthIndicator,
DiskHealthIndicator,
} from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private disk: DiskHealthIndicator
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
// Database health
() => this.db.pingCheck('database'),
// Disk health
() =>
this.disk.checkStorage('storage', {
path: '/',
thresholdPercent: 0.9,
}),
// Redis health
async () => {
const redis = await this.redis.ping();
return { redis: { status: redis === 'PONG' ? 'up' : 'down' } };
},
]);
}
}
```
### Health Check Response
```json
{
"status": "ok",
"info": {
"database": {
"status": "up"
},
"storage": {
"status": "up",
"freePercent": 0.75
},
"redis": {
"status": "up"
}
},
"error": {},
"details": {
"database": {
"status": "up"
},
"storage": {
"status": "up",
"freePercent": 0.75
},
"redis": {
"status": "up"
}
}
}
```
---
## 🐳 Docker Container Monitoring
### Health Check in docker-compose.yml
```yaml
services:
backend:
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
mariadb:
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
interval: 30s
timeout: 10s
retries: 3
redis:
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
```
### Monitor Container Status
```bash
#!/bin/bash
# File: /scripts/monitor-containers.sh
# Check all containers are healthy
CONTAINERS=("lcbp3-backend" "lcbp3-frontend" "lcbp3-mariadb" "lcbp3-redis")
for CONTAINER in "${CONTAINERS[@]}"; do
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $CONTAINER 2>/dev/null)
if [ "$HEALTH" != "healthy" ]; then
echo "ALERT: $CONTAINER is $HEALTH"
# Send alert (email, Slack, etc.)
fi
done
```
---
## 📈 Application Performance Monitoring (APM)
### Log-Based Monitoring (MVP Phase)
```typescript
// File: backend/src/common/interceptors/performance.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { logger } from 'src/config/logger.config';
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap({
next: () => {
const duration = Date.now() - start;
logger.info('Request completed', {
method: request.method,
url: request.url,
statusCode: context.switchToHttp().getResponse().statusCode,
duration: `${duration}ms`,
userId: request.user?.user_id,
});
// Alert on slow requests
if (duration > 1000) {
logger.warn('Slow request detected', {
method: request.method,
url: request.url,
duration: `${duration}ms`,
});
}
},
error: (error) => {
const duration = Date.now() - start;
logger.error('Request failed', {
method: request.method,
url: request.url,
duration: `${duration}ms`,
error: error.message,
});
},
})
);
}
}
```
---
## 🚨 Alerting Rules
### Critical Alerts (Immediate Action Required)
| Alert | Condition | Action |
| --------------- | ------------------------------------------- | --------------------------- |
| Service Down | Health check fails for 3 consecutive checks | Page on-call engineer |
| Database Down | Cannot connect to database | Page DBA + on-call engineer |
| Disk Full | Disk usage > 95% | Page operations team |
| High Error Rate | Error rate > 10% for 5 min | Page on-call engineer |
### Warning Alerts (Review Within 1 Hour)
| Alert | Condition | Action |
| ------------- | ----------------------- | ---------------------- |
| High CPU | CPU > 90% for 10 min | Notify operations team |
| High Memory | Memory > 95% for 10 min | Notify operations team |
| Slow Queries | > 50 slow queries/min | Notify DBA |
| Queue Backlog | BullMQ queue > 500 jobs | Notify backend team |
### Info Alerts (Review During Business Hours)
| Alert | Condition | Action |
| ------------------ | ------------------------------------ | --------------------- |
| Backup Failed | Daily backup job failed | Email operations team |
| SSL Expiring | SSL certificate expires in < 30 days | Email operations team |
| Disk Space Warning | Disk usage > 80% | Email operations team |
---
## 📧 Alert Notification Channels
### Email Alerts
```bash
#!/bin/bash
# File: /scripts/send-alert-email.sh
TO="ops-team@example.com"
SUBJECT="$1"
MESSAGE="$2"
echo "$MESSAGE" | mail -s "[LCBP3-DMS] $SUBJECT" "$TO"
```
### Slack Alerts
```bash
#!/bin/bash
# File: /scripts/send-alert-slack.sh
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
MESSAGE="$1"
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"🚨 LCBP3-DMS Alert: $MESSAGE\"}" \
"$WEBHOOK_URL"
```
---
## 📊 Monitoring Dashboard
### Metrics to Display
**System Overview:**
- Service status (up/down)
- Overall system health score
- Active user count
- Request rate (req/s)
**Performance:**
- API response time (P50, P95, P99)
- Database query time
- Queue processing time
**Resources:**
- CPU usage %
- Memory usage %
- Disk usage %
- Network I/O
**Business Metrics:**
- Documents created today
- Workflows completed today
- Active correspondences
- Pending approvals
---
## 🔧 Log Aggregation
### Centralized Logging with Docker
```bash
# Configure Docker logging driver
# File: /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3",
"labels": "service,environment"
}
}
```
### View Aggregated Logs
```bash
# View all LCBP3 container logs
docker-compose logs -f --tail=100
# View specific service logs
docker logs lcbp3-backend -f --since=1h
# Search logs
docker logs lcbp3-backend 2>&1 | grep "ERROR"
# Export logs for analysis
docker logs lcbp3-backend > backend-logs.txt
```
---
## 📈 Performance Baseline
### Establish Baselines
Run load tests to establish performance baselines:
```bash
# Install Apache Bench
apt-get install apache2-utils
# Test API endpoint
ab -n 1000 -c 10 \
-H "Authorization: Bearer <TOKEN>" \
https://lcbp3-dms.example.com/api/correspondences
# Results to record:
# - Requests per second
# - Mean response time
# - P95 response time
# - Error rate
```
### Regular Performance Testing
- **Weekly:** Quick health check (100 requests)
- **Monthly:** Full load test (10,000 requests)
- **Quarterly:** Stress test (find breaking point)
---
## ✅ Monitoring Checklist
### Daily
- [ ] Check service health dashboard
- [ ] Review error logs
- [ ] Verify backup completion
- [ ] Check disk space
### Weekly
- [ ] Review performance metrics trends
- [ ] Analyze slow query log
- [ ] Check SSL certificate expiry
- [ ] Review security alerts
### Monthly
- [ ] Capacity planning review
- [ ] Update monitoring thresholds
- [ ] Test alert notifications
- [ ] Review and tune performance
---
## 🔗 Related Documents
- [Backup & Recovery](./backup-recovery.md)
- [Incident Response](./incident-response.md)
- [ADR-010: Logging Strategy](../05-decisions/ADR-010-logging-monitoring-strategy.md)
---
**Version:** 1.5.0
**Last Review:** 2025-12-01
**Next Review:** 2026-03-01

View File

View File

@@ -0,0 +1,444 @@
# Security Operations
**Project:** LCBP3-DMS
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This document outlines security monitoring, access control management, vulnerability management, and security incident response for LCBP3-DMS.
---
## 🔒 Access Control Management
### User Access Review
**Monthly Tasks:**
```bash
#!/bin/bash
# File: /scripts/audit-user-access.sh
# Export active users
docker exec lcbp3-mariadb mysql -u root -p -e "
SELECT user_id, username, email, primary_organization_id, is_active, last_login_at
FROM lcbp3_dms.users
WHERE is_active = 1
ORDER BY last_login_at DESC;
" > /reports/active-users-$(date +%Y%m%d).csv
# Find dormant accounts (no login > 90 days)
docker exec lcbp3-mariadb mysql -u root -p -e "
SELECT user_id, username, email, last_login_at,
DATEDIFF(NOW(), last_login_at) AS days_inactive
FROM lcbp3_dms.users
WHERE is_active = 1
AND (last_login_at IS NULL OR last_login_at < DATE_SUB(NOW(), INTERVAL 90 DAY));
"
echo "User access audit completed: $(date)"
```
### Role & Permission Audit
```sql
-- Review users with elevated permissions
SELECT u.username, u.email, r.role_name, r.scope
FROM users u
JOIN user_assignments ua ON u.user_id = ua.user_id
JOIN roles r ON ua.role_id = r.role_id
WHERE r.role_name IN ('Superadmin', 'Document Controller', 'Project Manager')
ORDER BY r.role_name, u.username;
-- Review Global scope roles (highest privilege)
SELECT u.username, r.role_name
FROM users u
JOIN user_assignments ua ON u.user_id = ua.user_id
JOIN roles r ON ua.role_id = r.role_id
WHERE r.scope = 'Global';
```
---
## 🛡️ Security Monitoring
### Log Monitoring for Security Events
```bash
#!/bin/bash
# File: /scripts/monitor-security-events.sh
# Check for failed login attempts
docker logs lcbp3-backend | grep "Failed login" | tail -20
# Check for unauthorized access attempts (403)
docker logs lcbp3-backend | grep "403" | tail -20
# Check for unusual activity patterns
docker logs lcbp3-backend | grep -E "DELETE|DROP|TRUNCATE" | tail -20
# Check for SQL injection attempts
docker logs lcbp3-backend | grep -i "SELECT.*FROM.*WHERE" | grep -v "legitimate" | tail -20
```
### Failed Login Monitoring
```sql
-- Find accounts with multiple failed login attempts
SELECT username, failed_attempts, locked_until
FROM users
WHERE failed_attempts >= 3
ORDER BY failed_attempts DESC;
-- Unlock user account after verification
UPDATE users
SET failed_attempts = 0, locked_until = NULL
WHERE user_id = ?;
```
---
## 🔐 Secrets & Credentials Management
### Password Rotation Schedule
| Credential | Rotation Frequency | Owner |
| ---------------------- | ------------------------ | ------------ |
| Database Root Password | Every 90 days | DBA |
| Database App Password | Every 90 days | DevOps |
| JWT Secret | Every 180 days | Backend Team |
| Redis Password | Every 90 days | DevOps |
| SMTP Password | When provider requires | Operations |
| SSL Private Key | With certificate renewal | Operations |
### Password Rotation Procedure
```bash
#!/bin/bash
# File: /scripts/rotate-db-password.sh
# Generate new password
NEW_PASSWORD=$(openssl rand -base64 32)
# Update database user password
docker exec lcbp3-mariadb mysql -u root -p -e "
ALTER USER 'lcbp3_user'@'%' IDENTIFIED BY '$NEW_PASSWORD';
FLUSH PRIVILEGES;
"
# Update application .env file
sed -i "s/^DB_PASS=.*/DB_PASS=$NEW_PASSWORD/" /app/backend/.env
# Restart backend to apply new password
docker restart lcbp3-backend
# Verify connection
sleep 10
curl -f http://localhost:3000/health || {
echo "FAILED: Backend cannot connect with new password"
# Rollback procedure...
exit 1
}
echo "Database password rotated successfully: $(date)"
# Store password securely (e.g., password manager)
```
---
## 🚨 Vulnerability Management
### Dependency Vulnerability Scanning
```bash
#!/bin/bash
# File: /scripts/scan-vulnerabilities.sh
# Backend dependencies
cd /app/backend
npm audit --production
# Critical/High vulnerabilities
VULNERABILITIES=$(npm audit --production --json | jq '.metadata.vulnerabilities.high + .metadata.vulnerabilities.critical')
if [ "$VULNERABILITIES" -gt 0 ]; then
echo "WARNING: $VULNERABILITIES critical/high vulnerabilities found!"
npm audit --production > /reports/security-audit-$(date +%Y%m%d).txt
# Send alert
/scripts/send-alert-email.sh "Security Vulnerabilities Detected" "Found $VULNERABILITIES critical/high vulnerabilities"
fi
# Frontend dependencies
cd /app/frontend
npm audit --production
```
### Container Image Scanning
```bash
#!/bin/bash
# File: /scripts/scan-images.sh
# Install Trivy (if not installed)
# wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | apt-key add -
# echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | tee -a /etc/apt/sources.list.d/trivy.list
# apt-get update && apt-get install trivy
# Scan Docker images
trivy image --severity HIGH,CRITICAL lcbp3-backend:latest
trivy image --severity HIGH,CRITICAL lcbp3-frontend:latest
trivy image --severity HIGH,CRITICAL mariadb:10.11
trivy image --severity HIGH,CRITICAL redis:7.2-alpine
```
---
## 🔍 Security Hardening
### Server Hardening Checklist
- [ ] Disable root SSH login
- [ ] Use SSH key authentication only
- [ ] Configure firewall (allow only necessary ports)
- [ ] Enable automatic security updates
- [ ] Remove unnecessary services
- [ ] Configure fail2ban for brute-force protection
- [ ] Enable SELinux/AppArmor
- [ ] Regular security patch updates
### Docker Security
```yaml
# docker-compose.yml - Security best practices
services:
backend:
# Run as non-root user
user: 'node:node'
# Read-only root filesystem
read_only: true
# No new privileges
security_opt:
- no-new-privileges:true
# Limit capabilities
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
# Resource limits
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
memory: 512M
```
### Database Security
```sql
-- Remove anonymous users
DELETE FROM mysql.user WHERE User='';
-- Remove test database
DROP DATABASE IF EXISTS test;
-- Remove remote root login
DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1');
-- Create dedicated backup user with minimal privileges
CREATE USER 'backup_user'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD';
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON lcbp3_dms.* TO 'backup_user'@'localhost';
-- Enable SSL for database connections
-- GRANT USAGE ON *.* TO 'lcbp3_user'@'%' REQUIRE SSL;
FLUSH PRIVILEGES;
```
---
## 🚨 Security Incident Response
### Incident Classification
| Type | Examples | Response Time |
| ----------------------- | ---------------------------- | ---------------- |
| **Data Breach** | Unauthorized data access | Immediate (< 1h) |
| **Account Compromise** | Stolen credentials | Immediate (< 1h) |
| **DDoS Attack** | Service unavailable | Immediate (< 1h) |
| **Malware/Ransomware** | Infected systems | Immediate (< 1h) |
| **Unauthorized Access** | Failed authentication spikes | High (< 4h) |
| **Suspicious Activity** | Unusual patterns | Medium (< 24h) |
### Data Breach Response
**Immediate Actions:**
1. **Contain the breach**
```bash
# Block suspicious IPs at firewall level
iptables -A INPUT -s <SUSPICIOUS_IP> -j DROP
# Disable compromised user accounts
docker exec lcbp3-mariadb mysql -u root -p -e "
UPDATE lcbp3_dms.users
SET is_active = 0
WHERE user_id = <COMPROMISED_USER_ID>;
"
```
2. **Assess impact**
```sql
-- Check audit logs for unauthorized access
SELECT * FROM audit_logs
WHERE user_id = <COMPROMISED_USER_ID>
AND created_at >= '<SUSPECTED_START_TIME>'
ORDER BY created_at DESC;
-- Check what documents were accessed
SELECT DISTINCT entity_id, entity_type, action
FROM audit_logs
WHERE user_id = <COMPROMISED_USER_ID>;
```
3. **Notify stakeholders**
- Security officer
- Management
- Affected users (if applicable)
- Legal team (if required by law)
4. **Document everything**
- Timeline of events
- Data accessed/compromised
- Actions taken
- Lessons learned
### Account Compromise Response
```bash
#!/bin/bash
# File: /scripts/respond-account-compromise.sh
USER_ID=$1
# 1. Immediately disable account
docker exec lcbp3-mariadb mysql -u root -p -e "
UPDATE lcbp3_dms.users
SET is_active = 0,
locked_until = DATE_ADD(NOW(), INTERVAL 24 HOUR)
WHERE user_id = $USER_ID;
"
# 2. Invalidate all sessions
docker exec lcbp3-redis redis-cli DEL "session:user:$USER_ID:*"
# 3. Generate audit report
docker exec lcbp3-mariadb mysql -u root -p -e "
SELECT * FROM lcbp3_dms.audit_logs
WHERE user_id = $USER_ID
AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
ORDER BY created_at DESC;
" > /reports/compromise-audit-user-$USER_ID-$(date +%Y%m%d).txt
# 4. Notify security team
/scripts/send-alert-email.sh "Account Compromise" "User ID $USER_ID has been compromised and disabled"
echo "Account compromise response completed for User ID: $USER_ID"
```
---
## 📊 Security Metrics & KPIs
### Monthly Security Report
| Metric | Target | Actual |
| --------------------------- | --------- | ------ |
| Failed Login Attempts | < 100/day | Track |
| Locked Accounts | < 5/month | Track |
| Critical Vulnerabilities | 0 | Track |
| High Vulnerabilities | < 5 | Track |
| Unpatched Systems | 0 | Track |
| Security Incidents | 0 | Track |
| Mean Time To Detect (MTTD) | < 1 hour | Track |
| Mean Time To Respond (MTTR) | < 4 hours | Track |
---
## 🔐 Compliance & Audit
### Audit Log Retention
- **Access Logs:** 1 year
- **Security Events:** 2 years
- **Admin Actions:** 3 years
- **Data Changes:** 7 years (as required)
### Compliance Checklist
- [ ] Regular security audits (quarterly)
- [ ] Penetration testing (annually)
- [ ] Access control reviews (monthly)
- [ ] Encryption at rest and in transit
- [ ] Secure password policies enforced
- [ ] Multi-factor authentication (if required)
- [ ] Data backup and recovery tested
- [ ] Incident response plan documented and tested
---
## ✅ Security Operations Checklist
### Daily
- [ ] Review security alerts and logs
- [ ] Monitor failed login attempts
- [ ] Check for unusual access patterns
- [ ] Verify backup completion
### Weekly
- [ ] Review user access logs
- [ ] Scan for vulnerabilities
- [ ] Update virus definitions
- [ ] Review firewall logs
### Monthly
- [ ] User access audit
- [ ] Role and permission review
- [ ] Security patch application
- [ ] Compliance review
### Quarterly
- [ ] Full security audit
- [ ] Penetration testing
- [ ] Disaster recovery drill
- [ ] Update security policies
---
## 🔗 Related Documents
- [Incident Response](./incident-response.md)
- [Monitoring & Alerting](./monitoring-alerting.md)
- [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
---
**Version:** 1.5.0
**Last Review:** 2025-12-01
**Next Review:** 2026-03-01

View File

@@ -0,0 +1,353 @@
# ADR-001: Unified Workflow Engine
**Status:** Accepted
**Date:** 2025-11-30
**Decision Makers:** Development Team, System Architect
**Related Documents:**
- [System Architecture](../02-architecture/system-architecture.md)
- [Unified Workflow Requirements](../01-requirements/03.6-unified-workflow.md)
---
## Context and Problem Statement
LCBP3-DMS ต้องจัดการเอกสารหลายประเภท (Correspondences, RFAs, Circulations) โดยแต่ละประเภทมี Workflow การเดินเอกสารที่แตกต่างกัน:
- **Correspondence Routing:** ส่งเอกสารระหว่างองค์กร มีการ Forward, Reply
- **RFA Approval Workflow:** ส่งขออนุมัติ มีขั้นตอน Review → Approve → Respond
- **Circulation Workflow:** เวียนเอกสารภายในองค์กร มีการ Assign ผู้รับเพื่อพิจารณา
### Key Problems
1. **Code Duplication:** หากสร้างตาราง Routing แยกกันสำหรับแต่ละประเภทเอกสาร จะมี Logic ซ้ำซ้อน
2. **Complexity:** การ Maintain หลาย Workflow Systems ทำให้ซับซ้อน
3. **Inconsistency:** State Management และ History Tracking อาจไม่สอดคล้องกัน
4. **Scalability:** เมื่อเพิ่มประเภทเอกสารใหม่ ต้องสร้าง Workflow System ใหม่
5. **Versioning:** การแก้ไข Workflow กระทบเอกสารที่กำลังดำเนินการอยู่
---
## Decision Drivers
- **DRY Principle:** Don't Repeat Yourself - ลดการเขียน Code ซ้ำ
- **Maintainability:** ง่ายต่อการ Maintain และ Debug
- **Flexibility:** รองรับการเปลี่ยนแปลง Workflow ในอนาคต
- **Traceability:** ติดตามประวัติการเปลี่ยนสถานะได้ชัดเจน
- **Performance:** ประมวลผล Workflow ได้เร็วและมีประสิทธิภาพ
---
## Considered Options
### Option 1: Hard-coded Workflow per Document Type
**แนวทาง:** สร้างตาราง `correspondence_routings`, `rfa_approvals`, `circulation_routings` แยกกัน
**Pros:**
- ✅ เข้าใจง่าย straightforward
- ✅ Query performance ดี (table-specific indexes)
- ✅ Schema ชัดเจนสำหรับแต่ละ type
**Cons:**
- ❌ Code duplication มาก
- ❌ ยากต่อการเพิ่ม Document Type ใหม่
- ❌ Inconsistent state management
- ❌ ไม่มี Workflow versioning mechanism
- ❌ ยากต่อการ reuse common workflows
### Option 2: Generic Workflow Engine with Hard-coded State Machines
**แนวทาง:** สร้าง Workflow Engine แต่ Hard-code State Machine ไว้ใน Code
**Pros:**
- ✅ Centralized workflow logic
- ✅ Reusable workflow components
- ✅ Better maintainability
**Cons:**
- ❌ ต้อง Deploy ใหม่ทุกครั้งที่แก้ Workflow
- ❌ ไม่ยืดหยุ่นสำหรับ Business Users
- ❌ Versioning ยังซับซ้อน
### Option 3: **DSL-Based Unified Workflow Engine** ⭐ (Selected)
**แนวทาง:** สร้าง Workflow Engine ที่ใช้ JSON-based DSL (Domain Specific Language) เพื่อ Define Workflows
**Pros:**
-**Single Source of Truth:** Workflow logic อยู่ใน Database
-**Versioning Support:** เก็บ Workflow Definition versions ได้
-**Runtime Flexibility:** แก้ Workflow ได้โดยไม่ต้อง Deploy
-**Reusability:** Workflow templates สามารถใช้ซ้ำได้
-**Consistency:** State management เป็นมาตรฐานเดียวกัน
-**Audit Trail:** ประวัติครบถ้วนใน `workflow_history`
-**Scalability:** เพิ่ม Document Type ใหม่ได้ง่าย
**Cons:**
- ❌ Initial development complexity สูง
- ❌ ต้องเขียน DSL Parser และ Validator
- ❌ Performance overhead เล็กน้อย (parse JSON)
- ❌ Learning curve สำหรับทีม
---
## Decision Outcome
**Chosen Option:** Option 3 - DSL-Based Unified Workflow Engine
### Rationale
เลือก Unified Workflow Engine เนื่องจาก:
1. **Long-term Maintainability:** แม้จะมี complexity ในการพัฒนา แต่ในระยะยาวจะลดภาระการ Maintain
2. **Business Flexibility:** Business Users สามารถปรับ Workflow ได้ (ผ่าน Admin UI ในอนาคต)
3. **Consistency:** สถานะและประวัติเป็นมาตรฐานเดียวกันทุก Document Type
4. **Scalability:** เตรียมพร้อมสำหรับ Document Types ใหม่ๆ ในอนาคต
5. **Versioning:** รองรับการแก้ไข Workflow โดยไม่กระทบ In-progress documents
---
## Implementation Details
### Database Schema
```sql
-- Workflow Definitions (Templates)
CREATE TABLE workflow_definitions (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
version INT NOT NULL,
entity_type ENUM('correspondence', 'rfa', 'circulation'),
definition JSON NOT NULL, -- DSL Configuration
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (name, version)
);
-- Workflow Instances (Running Workflows)
CREATE TABLE workflow_instances (
id INT PRIMARY KEY AUTO_INCREMENT,
definition_id INT NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id INT NOT NULL,
current_state VARCHAR(50) NOT NULL,
context JSON, -- Runtime data
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (definition_id) REFERENCES workflow_definitions(id)
);
-- Workflow History (Audit Trail)
CREATE TABLE workflow_history (
id INT PRIMARY KEY AUTO_INCREMENT,
instance_id INT NOT NULL,
from_state VARCHAR(50),
to_state VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
actor_id INT NOT NULL,
metadata JSON,
transitioned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES workflow_instances(id),
FOREIGN KEY (actor_id) REFERENCES users(user_id)
);
```
### DSL Example
```json
{
"name": "CORRESPONDENCE_ROUTING",
"version": 1,
"entity_type": "correspondence",
"states": [
{
"name": "DRAFT",
"type": "initial",
"allowed_transitions": ["SUBMIT"]
},
{
"name": "SUBMITTED",
"type": "intermediate",
"allowed_transitions": ["RECEIVE", "RETURN", "CANCEL"]
},
{
"name": "RECEIVED",
"type": "intermediate",
"allowed_transitions": ["REPLY", "FORWARD", "CLOSE"]
},
{
"name": "CLOSED",
"type": "final"
}
],
"transitions": [
{
"action": "SUBMIT",
"from": "DRAFT",
"to": "SUBMITTED",
"guards": [
{
"type": "permission",
"permission": "correspondence.submit"
},
{
"type": "validation",
"rules": ["hasRecipient", "hasAttachment"]
}
],
"effects": [
{
"type": "notification",
"template": "correspondence_submitted",
"recipients": ["originator", "assigned_reviewer"]
},
{
"type": "update_entity",
"field": "submitted_at",
"value": "{{now}}"
}
]
}
]
}
```
### NestJS Module Structure
```typescript
// workflow-engine.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([
WorkflowDefinition,
WorkflowInstance,
WorkflowHistory,
]),
],
providers: [
WorkflowEngineService,
WorkflowDefinitionService,
WorkflowInstanceService,
DslParserService,
StateValidator,
TransitionExecutor,
],
exports: [WorkflowEngineService],
})
export class WorkflowEngineModule {}
// workflow-engine.service.ts
@Injectable()
export class WorkflowEngineService {
async createInstance(
definitionId: number,
entityType: string,
entityId: number
): Promise<WorkflowInstance> {
const definition = await this.getActiveDefinition(definitionId);
const initialState = this.dslParser.getInitialState(definition.definition);
return this.instanceRepo.save({
definition_id: definitionId,
entity_type: entityType,
entity_id: entityId,
current_state: initialState,
});
}
async executeTransition(
instanceId: number,
action: string,
actorId: number
): Promise<void> {
const instance = await this.instanceRepo.findOne(instanceId);
const definition = await this.definitionRepo.findOne(
instance.definition_id
);
// Validate transition
const transition = this.stateValidator.validateTransition(
definition.definition,
instance.current_state,
action
);
// Execute guards
await this.checkGuards(transition.guards, instance, actorId);
// Update state
await this.transitionExecutor.execute(instance, transition, actorId);
// Record history
await this.recordHistory(instance, transition, actorId);
}
}
```
---
## Consequences
### Positive
1.**Unified State Management:** สถานะทุก Document Type จัดการโดย Engine เดียว
2.**No Code Changes for Workflow Updates:** แก้ Workflow ผ่าน JSON DSL
3.**Complete Audit Trail:** ประวัติครบถ้วนใน `workflow_history`
4.**Versioning Support:** In-progress documents ใช้ Workflow Version เดิม
5.**Reusable Templates:** สามารถ Clone Workflow Template ได้
6.**Future-proof:** พร้อมสำหรับ Document Types ใหม่
### Negative
1.**Initial Complexity:** ต้องสร้าง DSL Parser, Validator, Executor
2.**Learning Curve:** ทีมต้องเรียนรู้ DSL Structure
3.**Performance:** เพิ่ม overhead เล็กน้อยจากการ parse JSON
4.**Debugging:** ยากกว่า Hard-coded logic เล็กน้อย
5.**Testing:** ต้อง Test ทั้ง Engine และ Workflow Definitions
### Mitigation Strategies
- **Complexity:** สร้าง UI Builder สำหรับ Workflow Design ในอนาคต
- **Learning Curve:** เขียน Documentation และ Examples ที่ชัดเจน
- **Performance:** ใช้ Redis Cache สำหรับ Workflow Definitions
- **Debugging:** สร้าง Workflow Visualization Tool
- **Testing:** เขียน Comprehensive Unit Tests สำหรับ Engine
---
## Compliance
เป็นไปตาม:
- [Backend Plan Section 2.4.1](../../docs/2_Backend_Plan_V1_4_5.md) - Unified Workflow Engine
- [Requirements 3.6](../01-requirements/03.6-unified-workflow.md) - Unified Workflow Specification
---
## Notes
- Workflow DSL จะถูก Validate ด้วย JSON Schema ก่อน Save
- Admin UI สำหรับจัดการ Workflow จะพัฒนาใน Phase 2
- ต้องมี Migration Tool สำหรับ Workflow Definition Changes
- พิจารณาใช้ BPMN 2.0 Notation ในอนาคต (ถ้าต้องการ Visual Workflow Designer)
---
## Related ADRs
- [ADR-002: Document Numbering Strategy](./ADR-002-document-numbering-strategy.md) - ใช้ Workflow Engine trigger Document Number Generation
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md) - Permission Guards ใน Workflow Transitions
---
## References
- [NestJS State Machine Example](https://docs.nestjs.com/techniques/queues)
- [Workflow Patterns](http://www.workflowpatterns.com/)
- [JSON Schema Specification](https://json-schema.org/)

View File

@@ -0,0 +1,432 @@
# ADR-002: Document Numbering Strategy
**Status:** Accepted
**Date:** 2025-11-30
**Decision Makers:** Development Team, System Architect
**Related Documents:**
- [System Architecture](../02-architecture/system-architecture.md)
- [Document Numbering Requirements](../01-requirements/03.11-document-numbering.md)
---
## Context and Problem Statement
LCBP3-DMS ต้องสร้างเลขที่เอกสารอัตโนมัติสำหรับ Correspondences และ RF
As โดยเลขที่เอกสารต้อง:
1. **Unique:** ไม่ซ้ำกันในระบบ
2. **Sequential:** เรียงตามลำดับเวลา
3. **Meaningful:** มีโครงสร้างที่อ่านเข้าใจได้ (เช่น `TEAM-RFA-STR-2025-0001`)
4. **Configurable:** สามารถปรับรูปแบบได้ตาม Project/Organization
5. **Concurrent-safe:** ป้องกัน Race Condition เมื่อมีหลาย Request พร้อมกัน
### Key Challenges
1. **Race Condition:** เมื่อมี 2+ requests พร้อมกัน อาจได้เลขเดียวกัน
2. **Performance:** ต้องรวดเร็วแม้มี concurrent requests
3. **Flexibility:** รองรับรูปแบบเลขที่หลากหลาย
4. **Discipline Support:** เลขที่ต้องรวม Discipline Code (GEN, STR, ARC)
---
## Decision Drivers
- **Data Integrity:** เลขที่ต้องไม่ซ้ำกันเด็ดขาด
- **Performance:** Generate เลขที่ได้เร็ว (< 100ms)
- **Scalability:** รองรับ concurrent requests สูง
- **Maintainability:** ง่ายต่อการ Config และ Debug
- **Flexibility:** รองรับรูปแบบที่หลากหลาย
---
## Considered Options
### Option 1: Database AUTO_INCREMENT
**แนวทาง:** ใช้ MySQL AUTO_INCREMENT column
**Pros:**
- ✅ Simple implementation
- ✅ Database handles uniqueness
- ✅ Very fast performance
**Cons:**
- ❌ ไม่ Configurable (รูปแบบเลขที่ fixed)
- ❌ ยากต่อการ Partition by Project/Type/Year
- ❌ ไม่รองรับ Custom format (เช่น `TEAM-RFA-2025-0001`)
- ❌ Reset ตาม Year ทำได้ยาก
### Option 2: Application-Level Counter (Single Lock)
**แนวทาง:** ใช้ Redis INCR สำหรับ Counter
**Pros:**
- ✅ Fast performance (Redis in-memory)
- ✅ Configurable format
- ✅ Easy to partition (different Redis keys)
**Cons:**
- ❌ Single Point of Failure (ถ้า Redis down)
- ❌ ไม่มี Persistence ถ้า Redis crash (ถ้าไม่ใช้ AOF/RDB)
- ❌ Difficult to audit (ไม่มี history ใน DB)
### Option 3: **Double-Lock Mechanism (Redis + Database)** ⭐ (Selected)
**แนวทาง:** ใช้ Redis Distributed Lock + Database Optimistic Locking + Version Column
**Pros:**
-**Guaranteed Uniqueness:** Double-layer protection
-**Fast Performance:** Redis lock prevents most conflicts
-**Audit Trail:** Counter history in database
-**Configurable Format:** Template-based generation
-**Resilient:** Fallback to DB if Redis issues
-**Partition Support:** Different counters per Project/Type/Discipline/Year
**Cons:**
- ❌ More complex implementation
- ❌ Slightly slower than pure Redis (but still fast)
- ❌ Requires both Redis and DB
---
## Decision Outcome
**Chosen Option:** Option 3 - Double-Lock Mechanism (Redis + Database)
### Rationale
เลือก Double-Lock เนื่องจาก:
1. **Mission-Critical:** เลขที่เอกสารต้องถูกต้อง 100% (ไม่ยอมรับการซ้ำ)
2. **Performance + Safety:** Balance ระหว่างความเร็วและความปลอดภัย
3. **Auditability:** มี Counter history ใน Database
4. **Flexibility:** รองรับ Template-based format
5. **Resilience:** ถ้า Redis มีปัญหา ยัง Fallback ไปใช้ DB Lock ได้
---
## Implementation Details
### Database Schema
```sql
-- Format Templates
CREATE TABLE document_number_formats (
id INT PRIMARY KEY AUTO_INCREMENT,
project_id INT NOT NULL,
correspondence_type_id INT NOT NULL,
format_template VARCHAR(255) NOT NULL,
-- Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}'
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id),
UNIQUE KEY (project_id, correspondence_type_id)
);
-- Counter Table with Optimistic Locking
CREATE TABLE document_number_counters (
project_id INT NOT NULL,
originator_organization_id INT NOT NULL,
correspondence_type_id INT NOT NULL,
discipline_id INT DEFAULT 0, -- 0 = no discipline
current_year INT NOT NULL,
last_number INT DEFAULT 0,
version INT DEFAULT 0 NOT NULL, -- Version for Optimistic Lock
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (project_id, originator_organization_id, correspondence_type_id, discipline_id, current_year),
FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (originator_organization_id) REFERENCES organizations(id),
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id),
FOREIGN KEY (discipline_id) REFERENCES disciplines(id)
);
```
### NestJS Service Implementation
```typescript
// document-numbering.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import Redlock from 'redlock';
import Redis from 'ioredis';
interface NumberingContext {
projectId: number;
organizationId: number;
typeId: number;
disciplineId?: number;
year?: number;
}
@Injectable()
export class DocumentNumberingService {
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
private redis: Redis,
private redlock: Redlock
) {}
async generateNextNumber(context: NumberingContext): Promise<string> {
const year = context.year || new Date().getFullYear();
const disciplineId = context.disciplineId || 0;
// Step 1: Acquire Redis Distributed Lock
const lockKey = `doc_num:${context.projectId}:${context.organizationId}:${context.typeId}:${disciplineId}:${year}`;
const lock = await this.redlock.acquire([lockKey], 3000); // 3 second TTL
try {
// Step 2: Query current counter with version
let counter = await this.counterRepo.findOne({
where: {
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
},
});
// Initialize counter if not exists
if (!counter) {
counter = this.counterRepo.create({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
last_number: 0,
version: 0,
});
}
const currentVersion = counter.version;
const nextNumber = counter.last_number + 1;
// Step 3: Update counter with Optimistic Lock check
const result = await this.counterRepo
.createQueryBuilder()
.update(DocumentNumberCounter)
.set({
last_number: nextNumber,
version: () => 'version + 1',
})
.where({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
version: currentVersion, // Optimistic lock check
})
.execute();
if (result.affected === 0) {
throw new Error('Optimistic lock conflict - counter version changed');
}
// Step 4: Generate formatted number
const format = await this.getFormat(context.projectId, context.typeId);
const formattedNumber = await this.formatNumber(format, {
...context,
year,
sequenceNumber: nextNumber,
});
return formattedNumber;
} finally {
// Step 5: Release Redis lock
await lock.release();
}
}
private async formatNumber(
format: DocumentNumberFormat,
data: any
): Promise<string> {
let result = format.format_template;
// Replace tokens
const tokens = {
'{ORG_CODE}': await this.getOrgCode(data.organizationId),
'{TYPE_CODE}': await this.getTypeCode(data.typeId),
'{DISCIPLINE_CODE}': await this.getDisciplineCode(data.disciplineId),
'{YEAR}': data.year.toString(),
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
};
for (const [token, value] of Object.entries(tokens)) {
result = result.replace(token, value);
}
return result;
}
}
```
### Algorithm Flow
```mermaid
sequenceDiagram
participant Service as Correspondence Service
participant Numbering as Numbering Service
participant Redis
participant DB as MariaDB
Service->>Numbering: generateNextNumber(context)
Numbering->>Redis: ACQUIRE Lock (key)
alt Lock Acquired
Redis-->>Numbering: Lock Success
Numbering->>DB: SELECT counter (with version)
DB-->>Numbering: current_number, version
Numbering->>DB: UPDATE counter SET last_number = X, version = version + 1<br/>WHERE version = old_version
alt Update Success
DB-->>Numbering: Success (1 row affected)
Numbering->>Numbering: Format Number
Numbering->>Redis: RELEASE Lock
Numbering-->>Service: Document Number
else Version Conflict
DB-->>Numbering: Failed (0 rows affected)
Numbering->>Redis: RELEASE Lock
Numbering->>Numbering: Retry with Exponential Backoff
end
else Lock Failed
Redis-->>Numbering: Lock Timeout
Numbering-->>Service: Error: Unable to acquire lock
end
```
---
## Consequences
### Positive
1.**Zero Duplicate Risk:** Double-lock guarantees uniqueness
2.**High Performance:** Redis lock prevents most DB conflicts (< 100ms)
3.**Audit Trail:** All counters stored in database
4.**Template-Based:** Easy to configure different formats
5.**Partition Support:** Separate counters per Project/Type/Discipline/Year
6.**Discipline Integration:** รองรับ Discipline Code ตาม Requirement 6B
### Negative
1.**Complexity:** Requires Redis + Database coordination
2.**Dependencies:** Requires both Redis and DB healthy
3.**Retry Logic:** May retry on optimistic lock conflicts
4.**Monitoring:** Need to monitor lock acquisition times
### Mitigation Strategies
- **Redis Dependency:** Use Redis Persistence (AOF) และ Replication
- **Complexity:** Encapsulate logic in `DocumentNumberingService`
- **Retry:** Exponential backoff with max 3 retries
- **Monitoring:** Track lock wait times และ conflict rates
---
## Testing Strategy
### Unit Tests
```typescript
describe('DocumentNumberingService - Concurrent Generation', () => {
it('should generate unique numbers for 100 concurrent requests', async () => {
const context = {
projectId: 1,
organizationId: 1,
typeId: 1,
disciplineId: 2, // STR
year: 2025,
};
const promises = Array(100)
.fill(null)
.map(() => service.generateNextNumber(context));
const results = await Promise.all(promises);
// Check uniqueness
const unique = new Set(results);
expect(unique.size).toBe(100);
// Check sequential
const numbers = results.map((r) => parseInt(r.split('-').pop()));
const sorted = [...numbers].sort((a, b) => a - b);
expect(numbers.every((n, i) => sorted.includes(n))).toBe(true);
});
it('should use correct format template', async () => {
const number = await service.generateNextNumber({
projectId: 1,
organizationId: 3, // TEAM
typeId: 1, // RFA
disciplineId: 2, // STR
year: 2025,
});
expect(number).toMatch(/^TEAM-RFA-STR-2025-\d{4}$/);
});
});
```
### Load Testing
```yaml
# Artillery configuration
config:
target: 'http://localhost:3000'
phases:
- duration: 60
arrivalRate: 50 # 50 requests/second
scenarios:
- name: 'Generate Document Numbers'
flow:
- post:
url: '/correspondences'
json:
title: 'Load Test {{ $randomString() }}'
project_id: 1
type_id: 1
discipline_id: 2
```
---
## Compliance
เป็นไปตาม:
- [Backend Plan Section 4.2.10](../../docs/2_Backend_Plan_V1_4_5.md) - DocumentNumberingModule
- [Requirements 3.11](../01-requirements/03.11-document-numbering.md) - Document Numbering
- [Requirements 6B](../../docs/2_Backend_Plan_V1_4_4.Phase6B.md) - Discipline Support
---
## Related ADRs
- [ADR-001: Unified Workflow Engine](./ADR-001-unified-workflow-engine.md) - Workflow triggers number generation
- [ADR-005: Redis Usage Strategy](./ADR-005-redis-usage-strategy.md) - Redis lock implementation
---
## References
- [Redlock Algorithm](https://redis.io/topics/distlock)
- [TypeORM Optimistic Locking](https://typeorm.io/entities#version-column)
- [Distributed Lock Patterns](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)

View File

@@ -0,0 +1,505 @@
# ADR-003: Two-Phase File Storage Approach
**Status:** Accepted
**Date:** 2025-11-30
**Decision Makers:** Development Team, System Architect
**Related Documents:**
- [System Architecture](../02-architecture/system-architecture.md)
- [File Handling Requirements](../01-requirements/03.10-file-handling.md)
---
## Context and Problem Statement
LCBP3-DMS ต้องจัดการ File Uploads สำหรับ Attachments ของเอกสาร (PDF, DWG, DOCX, etc.) โดยต้องรับมือกับปัญหา:
1. **Orphan Files:** User อัพโหลดไฟล์แล้วไม่ Submit Form → ไฟล์ค้างใน Storage
2. **Transaction Integrity:** ถ้า Database Transaction Rollback → ไฟล์ยังอยู่ใน Storage
3. **Virus Scanning:** ต้อง Scan ไฟล์ก่อน Save
4. **File Validation:** ตรวจสอบ Type, Size, Checksum
5. **Storage Organization:** จัดเก็บไฟล์อย่างเป็นระเบียบ
### Key Challenges
- **Orphan File Problem:** ไฟล์ที่ไม่เคยถูก Link กับ Document
- **Data Consistency:** ต้อง Sync กับ Database Transaction
- **Performance:** Upload ต้องเร็ว (ไม่ Block Form Submission)
- **Security:** ป้องกัน Malicious Files
- **Storage Management:** จำกัด QNAP Storage Space
---
## Decision Drivers
- **Data Integrity:** File และ Database Record ต้อง Consistent
- **Security:** ป้องกัน Virus และ Malicious Files
- **User Experience:** Upload ต้องรวดเร็ว ไม่ Block UI
- **Storage Efficiency:** ไม่เก็บไฟล์ที่ไม่ใช้
- **Auditability:** ติดตามประวัติ File Operations
---
## Considered Options
### Option 1: Direct Upload to Permanent Storage
**แนวทาง:** อัพโหลดไฟล์ไปยัง Permanent Storage ทันที
**Pros:**
- ✅ Simple implementation
- ✅ Fast upload (one-step process)
- ✅ No intermediate storage
**Cons:**
- ❌ Orphan files ถ้า user ไม่ submit form
- ❌ ยากต่อการ Rollback ถ้า Transaction fail
- ❌ ต้อง Manual cleanup orphan files
- ❌ Security risk (file available before validation)
### Option 2: Upload after Form Submission
**แนวทาง:** Upload ไฟล์หลังจาก Submit Form
**Pros:**
- ✅ No orphan files
- ✅ Guaranteed consistency
**Cons:**
- ❌ Slow form submission (wait for upload)
- ❌ Poor UX (user waits for all files to upload)
- ❌ Transaction timeout risk (large files)
- ❌ ไม่ Support progress indication สำหรับแต่ละไฟล์
### Option 3: **Two-Phase Storage (Temp → Permanent)** ⭐ (Selected)
**แนวทาง:** Upload ไปยัง Temporary Storage ก่อน → Commit เมื่อ Submit Form สำเร็จ
**Pros:**
-**Fast Upload:** User upload ได้เลย ไม่ต้องรอ Submit
-**No Orphan Files:** Temp files cleanup automatically
-**Transaction Safe:** Move to permanent only on commit
-**Better UX:** Show progress per file
-**Security:** Scan files before entering system
-**Audit Trail:** Track all file operations
**Cons:**
- ❌ More complex implementation
- ❌ Need cleanup job for expired temp files
- ❌ Extra storage space (temp directory)
---
## Decision Outcome
**Chosen Option:** Option 3 - Two-Phase Storage (Temp → Permanent)
### Rationale
เลือก Two-Phase Storage เนื่องจาก:
1. **Better User Experience:** Upload ไว ไม่ Block Form Submission
2. **Data Integrity:** Sync กับ Database Transaction ได้ดี
3. **No Orphan Files:** Auto-cleanup ไฟล์ที่ไม่ใช้
4. **Security:** Scan และ Validate ก่อน Commit
5. **Scalability:** รองรับ Large Files และ Multiple Files
---
## Implementation Details
### Database Schema
```sql
CREATE TABLE attachments (
id INT PRIMARY KEY AUTO_INCREMENT,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL, -- UUID-based
file_path VARCHAR(500) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
file_size INT NOT NULL,
checksum VARCHAR(64) NULL, -- SHA-256
-- Two-Phase Fields
is_temporary BOOLEAN DEFAULT TRUE,
temp_id VARCHAR(100) NULL, -- UUID for temp reference
expires_at DATETIME NULL, -- Temp file expiration
uploaded_by_user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (uploaded_by_user_id) REFERENCES users(user_id),
INDEX idx_temp_files (is_temporary, expires_at)
);
```
### Two-Phase Flow
```mermaid
sequenceDiagram
participant User as Client
participant BE as Backend
participant Virus as ClamAV
participant TempStorage as Temp Storage
participant PermStorage as Permanent Storage
participant DB as Database
Note over User,DB: Phase 1: Upload to Temporary Storage
User->>BE: POST /attachments/upload (file)
BE->>BE: Validate file type, size
BE->>Virus: Scan virus
alt File is CLEAN
Virus-->>BE: CLEAN
BE->>BE: Generate temp_id (UUID)
BE->>BE: Calculate SHA-256 checksum
BE->>TempStorage: Save to temp/{temp_id}
BE->>DB: INSERT attachment<br/>(is_temporary=TRUE, expires_at=NOW+24h)
BE-->>User: { temp_id, expires_at }
else File is INFECTED
Virus-->>BE: INFECTED
BE-->>User: Error: Virus detected
end
Note over User,DB: Phase 2: Commit to Permanent Storage
User->>BE: POST /correspondences<br/>{ temp_file_ids: [temp_id] }
BE->>DB: BEGIN Transaction
BE->>DB: INSERT correspondence
loop For each temp_file_id
BE->>TempStorage: Read temp file
BE->>PermStorage: Move to permanent/{YYYY}/{MM}/{UUID}
BE->>DB: UPDATE attachment<br/>(is_temporary=FALSE, file_path=new_path)
BE->>DB: INSERT correspondence_attachments
BE->>TempStorage: DELETE temp file
end
BE->>DB: COMMIT Transaction
BE-->>User: Success
Note over BE,TempStorage: Cleanup Job (Every 6 hours)
BE->>DB: SELECT expired temp files
BE->>TempStorage: DELETE expired files
BE->>DB: DELETE attachment records
```
### NestJS Service Implementation
```typescript
// file-storage.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash } from 'crypto';
import * as fs from 'fs-extra';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class FileStorageService {
private readonly TEMP_DIR: string;
private readonly PERMANENT_DIR: string;
private readonly TEMP_EXPIRY_HOURS = 24;
constructor(private config: ConfigService) {
this.TEMP_DIR = this.config.get('STORAGE_PATH') + '/temp';
this.PERMANENT_DIR = this.config.get('STORAGE_PATH') + '/permanent';
}
// Phase 1: Upload to Temporary
async uploadToTemp(file: Express.Multer.File): Promise<UploadResult> {
// 1. Validate file
this.validateFile(file);
// 2. Virus scan
await this.virusScan(file);
// 3. Generate temp ID
const tempId = uuidv4();
const storedFilename = `${tempId}_${file.originalname}`;
const tempPath = path.join(this.TEMP_DIR, storedFilename);
// 4. Calculate checksum
const checksum = await this.calculateChecksum(file.buffer);
// 5. Save to temp directory
await fs.writeFile(tempPath, file.buffer);
// 6. Create attachment record
const attachment = await this.attachmentRepo.save({
original_filename: file.originalname,
stored_filename: storedFilename,
file_path: tempPath,
mime_type: file.mimetype,
file_size: file.size,
checksum,
is_temporary: true,
temp_id: tempId,
expires_at: new Date(Date.now() + this.TEMP_EXPIRY_HOURS * 3600 * 1000),
uploaded_by_user_id: this.currentUserId,
});
return {
temp_id: tempId,
expires_at: attachment.expires_at,
filename: file.originalname,
size: file.size,
};
}
// Phase 2: Commit to Permanent (within Transaction)
async commitFiles(
tempIds: string[],
entityId: number,
entityType: string,
manager: EntityManager
): Promise<Attachment[]> {
const attachments = [];
for (const tempId of tempIds) {
// 1. Get temp attachment
const tempAttachment = await manager.findOne(Attachment, {
where: { temp_id: tempId, is_temporary: true },
});
if (!tempAttachment) {
throw new Error(`Temporary file not found: ${tempId}`);
}
// 2. Check expiration
if (tempAttachment.expires_at < new Date()) {
throw new Error(`Temporary file expired: ${tempId}`);
}
// 3. Generate permanent path
const now = new Date();
const year = now.getFullYear();
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const permanentDir = path.join(
this.PERMANENT_DIR,
year.toString(),
month
);
await fs.ensureDir(permanentDir);
const permanentFilename = `${uuidv4()}_${
tempAttachment.original_filename
}`;
const permanentPath = path.join(permanentDir, permanentFilename);
// 4. Move file
await fs.move(tempAttachment.file_path, permanentPath);
// 5. Update attachment record
await manager.update(
Attachment,
{ id: tempAttachment.id },
{
file_path: permanentPath,
stored_filename: permanentFilename,
is_temporary: false,
temp_id: null,
expires_at: null,
}
);
attachments.push(tempAttachment);
}
return attachments;
}
// Cleanup Job (Cron)
@Cron('0 */6 * * *') // Every 6 hours
async cleanupExpiredFiles(): Promise<void> {
const expiredFiles = await this.attachmentRepo.find({
where: {
is_temporary: true,
expires_at: LessThan(new Date()),
},
});
for (const file of expiredFiles) {
try {
// Delete physical file
await fs.remove(file.file_path);
// Delete DB record
await this.attachmentRepo.remove(file);
this.logger.log(`Cleaned up expired file: ${file.temp_id}`);
} catch (error) {
this.logger.error(`Failed to cleanup file: ${file.temp_id}`, error);
}
}
}
private async virusScan(file: Express.Multer.File): Promise<void> {
// ClamAV integration
const scanner = await this.clamAV.scan(file.buffer);
if (scanner.isInfected) {
throw new BadRequestException('Virus detected in file');
}
}
private validateFile(file: Express.Multer.File): void {
const allowedTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
const maxSize = 50 * 1024 * 1024; // 50MB
if (!allowedTypes.includes(file.mimetype)) {
throw new BadRequestException('Invalid file type');
}
if (file.size > maxSize) {
throw new BadRequestException('File too large (max 50MB)');
}
}
private async calculateChecksum(buffer: Buffer): Promise<string> {
return createHash('sha256').update(buffer).digest('hex');
}
}
```
### Controller Example
```typescript
@Controller('attachments')
export class AttachmentController {
// Phase 1: Upload
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(@UploadedFile() file: Express.Multer.File) {
return this.fileStorage.uploadToTemp(file);
}
}
@Controller('correspondences')
export class CorrespondenceController {
// Phase 2: Create with attachments
@Post()
async create(@Body() dto: CreateCorrespondenceDto) {
return this.dataSource.transaction(async (manager) => {
// 1. Create correspondence
const correspondence = await manager.save(Correspondence, {
title: dto.title,
project_id: dto.project_id,
// ...
});
// 2. Commit files (within transaction)
if (dto.temp_file_ids?.length > 0) {
await this.fileStorage.commitFiles(
dto.temp_file_ids,
correspondence.id,
'correspondence',
manager
);
}
return correspondence;
});
}
}
```
---
## Consequences
### Positive
1.**Fast Upload UX:** User upload แบบ Async ก่อน Submit
2.**No Orphan Files:** Auto-cleanup ไฟล์ที่หมดอายุ
3.**Transaction Safe:** Rollback ได้สมบูรณ์
4.**Security:** Virus scan ก่อน Commit
5.**Audit Trail:** ติดตาม Upload และ Commit operations
6.**Storage Organization:** จัดเก็บเป็น YYYY/MM structure
### Negative
1.**Complexity:** ต้อง Implement 2 phases
2.**Extra Storage:** ต้องมี Temp directory
3.**Cleanup Job:** ต้องรัน Cron job
4.**Edge Cases:** Handle expired files, missing temp files
### Mitigation Strategies
- **Complexity:** Encapsulate ใน `FileStorageService`
- **Storage:** Monitor และ Alert ถ้า Temp directory ใหญ่เกินไป
- **Cleanup:** Run Cron ทุก 6 ชั่วโมง + Alert ถ้า Fail
- **Edge Cases:** Proper error handling และ logging
---
## Security Considerations
### File Validation
1. **Type Validation:**
- Check MIME type
- Verify Magic Numbers (ไม่ใช่แค่ extension)
2. **Size Validation:**
- Max 50MB per file
- Total max 500MB per form submission
3. **Virus Scanning:**
- ClamAV integration
- Scan before saving to temp
4. **Checksum:**
- SHA-256 for integrity verification
- Detect file tampering
---
## Performance Considerations
### Upload Optimization
- **Streaming:** Use multipart/form-data streaming
- **Parallel Uploads:** Client upload multiple files กรณี
- **Progress Indication:** Return upload progress for large files
- **Chunk Upload:** Support resumable uploads (future)
### Storage Optimization
- **Compression:** Consider compressing certain file types
- **Deduplication:** Check checksum before storing (future)
- **CDN:** Consider CDN for frequently accessed files (future)
---
## Compliance
เป็นไปตาม:
- [Backend Plan Section 4.2.1](../../docs/2_Backend_Plan_V1_4_5.md) - FileStorageService
- [Requirements 3.10](../01-requirements/03.10-file-handling.md) - File Handling
- [System Architecture Section 5.2](../02-architecture/system-architecture.md) - File Upload Flow
---
## Related ADRs
- [ADR-006: Security Best Practices](./ADR-006-security-best-practices.md) - Virus scanning และ file validation
---
## References
- [ClamAV Documentation](https://docs.clamav.net/)
- [Multer Middleware](https://github.com/expressjs/multer)
- [File Upload Best Practices](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html)

View File

@@ -0,0 +1,423 @@
# ADR-004: RBAC Implementation with 4-Level Scope
**Status:** Accepted
**Date:** 2025-11-30
**Decision Makers:** Development Team, Security Team
**Related Documents:**
- [System Architecture](../02-architecture/system-architecture.md)
- [Access Control Requirements](../01-requirements/04-access-control.md)
---
## Context and Problem Statement
LCBP3-DMS ต้องจัดการสิทธิ์การเข้าถึงที่ซับซ้อน:
- **Multi-Organization:** หลายองค์กรใช้ระบบร่วมกัน แต่ต้องแยกข้อมูล
- **Project-Based:** แต่ละ Project มี Contracts แยกกัน
- **Hierarchical Permissions:** สิทธิ์ระดับบนครอบคลุมระดับล่าง
- **Dynamic Roles:** Role และ Permission ต้องปรับได้โดยไม่ต้อง Deploy
### Key Requirements
1. User หนึ่งคนสามารถมีหลาย Roles ในหลาย Scopes
2. Permission Inheritance (Global → Organization → Project → Contract)
3. Fine-grained Access Control (e.g., "ดู Correspondence ได้เฉพาะ Project A")
4. Performance (Check permission ต้องเร็ว < 10ms)
---
## Decision Drivers
- **Security:** ป้องกันการเข้าถึงข้อมูลที่ไม่มีสิทธิ์
- **Flexibility:** ปรับ Roles/Permissions ได้ง่าย
- **Performance:** Check permission รวดเร็ว
- **Usability:** Admin กำหนดสิทธิ์ได้ง่าย
- **Scalability:** รองรับ Users/Organizations จำนวนมาก
---
## Considered Options
### Option 1: Simple Role-Based (No Scope)
**แนวทาง:** Users มี Roles (Admin, Editor, Viewer) เท่านั้น ไม่มี Scope
**Pros:**
- ✅ Very simple implementation
- ✅ Easy to understand
**Cons:**
- ❌ ไม่รองรับ Multi-organization
- ❌ Superadmin เห็นข้อมูลทุก Organization
- ❌ ไม่ยืดหยุ่น
### Option 2: Organization-Only Scope
**แนวทาง:** Roles ผูกกับ Organization เท่านั้น
**Pros:**
- ✅ แยกข้อมูลระหว่าง Organizations ได้
- ✅ Moderate complexity
**Cons:**
- ❌ ไม่รองรับ Project/Contract level permissions
- ❌ User ใน Organization เห็นทุก Project
### Option 3: **4-Level Hierarchical RBAC** ⭐ (Selected)
**แนวทาง:** Global → Organization → Project → Contract
**Pros:**
-**Maximum Flexibility:** ครอบคลุมทุก Use Case
-**Inheritance:** Global Admin เห็นทุกอย่าง
-**Isolation:** Project Manager เห็นแค่ Project ของตน
-**Fine-grained:** Contract Admin จัดการแค่ Contract เดียว
-**Dynamic:** Roles/Permissions configurable
**Cons:**
- ❌ Complex implementation
- ❌ Performance concern (need optimization)
- ❌ Learning curve for admins
---
## Decision Outcome
**Chosen Option:** Option 3 - 4-Level Hierarchical RBAC
### Rationale
เลือก 4-Level RBAC เนื่องจาก:
1. **Business Requirements:** Project มีหลาย Contracts ที่ต้องแยกสิทธิ์
2. **Future-proof:** รองรับการเติบโตในอนาคต
3. **CASL Integration:** ใช้ library ที่รองรับ complex permissions
4. **Redis Caching:** แก้ปัญหา Performance ด้วย Cache
---
## Implementation Details
### Database Schema
```sql
-- Roles with Scope
CREATE TABLE roles (
role_id INT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(100) NOT NULL,
scope ENUM('Global', 'Organization', 'Project', 'Contract') NOT NULL,
description TEXT,
is_system BOOLEAN DEFAULT FALSE
);
-- Permissions
CREATE TABLE permissions (
permission_id INT PRIMARY KEY AUTO_INCREMENT,
permission_name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
module VARCHAR(50),
scope_level ENUM('GLOBAL', 'ORG', 'PROJECT')
);
-- Role-Permission Mapping
CREATE TABLE role_permissions (
role_id INT,
permission_id INT,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON DELETE CASCADE
);
-- User Role Assignments with Scope Context
CREATE TABLE user_assignments (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
role_id INT NOT NULL,
organization_id INT NULL,
project_id INT NULL,
contract_id INT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE,
CONSTRAINT chk_scope CHECK (
(organization_id IS NOT NULL AND project_id IS NULL AND contract_id IS NULL) OR
(organization_id IS NULL AND project_id IS NOT NULL AND contract_id IS NULL) OR
(organization_id IS NULL AND project_id IS NULL AND contract_id IS NOT NULL) OR
(organization_id IS NULL AND project_id IS NULL AND contract_id IS NULL)
)
);
```
### CASL Ability Rules
```typescript
// ability.factory.ts
import { AbilityBuilder, PureAbility } from '@casl/ability';
export type AppAbility = PureAbility<[string, any]>;
@Injectable()
export class AbilityFactory {
async createForUser(user: User): Promise<AppAbility> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility);
// Get user assignments (from cache or DB)
const assignments = await this.getUserAssignments(user.user_id);
for (const assignment of assignments) {
const role = await this.getRole(assignment.role_id);
const permissions = await this.getRolePermissions(role.role_id);
for (const permission of permissions) {
// permission format: 'correspondence.create', 'project.view'
const [subject, action] = permission.permission_name.split('.');
// Apply scope-based conditions
switch (assignment.scope) {
case 'Global':
can(action, subject);
break;
case 'Organization':
can(action, subject, {
organization_id: assignment.organization_id,
});
break;
case 'Project':
can(action, subject, {
project_id: assignment.project_id,
});
break;
case 'Contract':
can(action, subject, {
contract_id: assignment.contract_id,
});
break;
}
}
}
return build();
}
}
```
### Permission Guard
```typescript
// permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory,
private redis: Redis
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get required permission from decorator
const permission = this.reflector.get<string>(
'permission',
context.getHandler()
);
if (!permission) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check cache first (30 min TTL)
const cacheKey = `user:${user.user_id}:permissions`;
let ability = await this.redis.get(cacheKey);
if (!ability) {
ability = await this.abilityFactory.createForUser(user);
await this.redis.set(cacheKey, JSON.stringify(ability.rules), 'EX', 1800);
}
const [action, subject] = permission.split('.');
const resource = request.params || request.body;
return ability.can(action, subject, resource);
}
}
```
### Usage Example
```typescript
@Controller('correspondences')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class CorrespondenceController {
@Post()
@RequirePermission('correspondence.create')
async create(@Body() dto: CreateCorrespondenceDto) {
// Only users with create permission can access
}
@Get(':id')
@RequirePermission('correspondence.view')
async findOne(@Param('id') id: string) {
// Check if user has view permission for this project
}
}
```
---
## Permission Checking Flow
```mermaid
sequenceDiagram
participant Client
participant Guard as Permission Guard
participant Redis as Redis Cache
participant Factory as Ability Factory
participant DB as Database
Client->>Guard: Request with JWT
Guard->>Redis: Get user permissions (cache)
alt Cache Hit
Redis-->>Guard: Cached permissions
else Cache Miss
Guard->>Factory: createForUser(user)
Factory->>DB: Get user_assignments
Factory->>DB: Get role_permissions
Factory->>Factory: Build CASL ability
Factory-->>Guard: Ability object
Guard->>Redis: Cache permissions (TTL: 30min)
end
Guard->>Guard: Check permission.can(action, subject, context)
alt Permission Granted
Guard-->>Client: Allow access
else Permission Denied
Guard-->>Client: 403 Forbidden
end
```
---
## 4-Level Scope Hierarchy
```
Global (ทั้งระบบ)
├─ Organization (ระดับองค์กร)
│ ├─ Project (ระดับโครงการ)
│ │ └─ Contract (ระดับสัญญา)
│ │
│ └─ Project B
│ └─ Contract B
└─ Organization 2
└─ Project C
```
### Example Assignments
```typescript
// User A: Superadmin (Global)
{
user_id: 1,
role_id: 1, // Superadmin
organization_id: null,
project_id: null,
contract_id: null
}
// Can access EVERYTHING
// User B: Document Control in TEAM Organization
{
user_id: 2,
role_id: 3, // Document Control
organization_id: 3, // TEAM
project_id: null,
contract_id: null
}
// Can manage documents in TEAM organization (all projects)
// User C: Project Manager for LCBP3
{
user_id: 3,
role_id: 6, // Project Manager
organization_id: null,
project_id: 1, // LCBP3
contract_id: null
}
// Can manage only LCBP3 project (all contracts within)
// User D: Contract Admin for Contract-1
{
user_id: 4,
role_id: 7, // Contract Admin
organization_id: null,
project_id: null,
contract_id: 5 // Contract-1
}
// Can manage only Contract-1
```
---
## Consequences
### Positive
1.**Fine-grained Control:** แยกสิทธิ์ได้ละเอียดมาก
2.**Flexible:** User มีหลาย Roles ใน Scopes ต่างกันได้
3.**Inheritance:** Global → Org → Project → Contract
4.**Performant:** Redis cache ทำให้เร็ว (< 10ms)
5.**Auditable:** ทุก Assignment บันทึกใน DB
### Negative
1.**Complexity:** ซับซ้อนในการ Setup และ Maintain
2.**Cache Invalidation:** ต้อง Invalidate ถูกต้องเมื่อเปลี่ยน Roles
3.**Learning Curve:** Admin ต้องเข้าใจ Scope hierarchy
4.**Testing:** ต้อง Test ทุก Combination
### Mitigation Strategies
- **Complexity:** สร้าง Admin UI ที่ใช้งานง่าย
- **Cache:** Auto-invalidate เมื่อมีการเปลี่ยนแปลง
- **Documentation:** เขียน Guide ชัดเจน
- **Testing:** Integration tests ครอบคลุม Permissions
---
## Compliance
เป็นไปตาม:
- [Requirements Section 4](../01-requirements/04-access-control.md) - Access Control
- [Backend Plan Section 2 RBAC](../../docs/2_Backend_Plan_V1_4_5.md#rbac)
---
## Related ADRs
- [ADR-005: Redis Usage Strategy](./ADR-005-redis-usage-strategy.md) - Permission caching
- [ADR-001: Unified Workflow Engine](./ADR-001-unified-workflow-engine.md) - Workflow permission guards
---
## References
- [CASL Documentation](https://casl.js.org/v6/en/guide/intro)
- [RBAC Best Practices](https://csrc.nist.gov/publications/detail/sp/800-162/final)

View File

@@ -0,0 +1,291 @@
# ADR-005: Technology Stack Selection
**Status:** Accepted
**Date:** 2025-11-30
**Decision Makers:** Development Team, CTO
**Related Documents:**
- [System Architecture](../02-architecture/system-architecture.md)
- [FullStack JS Guidelines](../03-implementation/fullftack-js-v1.5.0.md)
---
## Context and Problem Statement
LCBP3-DMS ต้องเลือก Technology Stack สำหรับพัฒนา Document Management System ที่:
- รองรับ Multi-user Concurrent Access
- จัดการเอกสารซับซ้อนพร้อม Workflow
- Deploy บน QNAP Server (ข้อจำกัดด้าน Infrastructure)
- พัฒนาโดย Small Team (1-3 developers)
- Maintain ได้ในระยะยาว (5+ years)
---
## Decision Drivers
- **Development Speed:** พัฒนาได้เร็ว (6-12 months MVP)
- **Maintainability:** Maintain และ Scale ได้ง่าย
- **Team Expertise:** ทีมมีประสบการณ์ TypeScript/JavaScript
- **Infrastructure Constraints:** Deploy บน QNAP Container Station
- **Community Support:** มี Community และ Documentation ดี
- **Future-proof:** Technology ยังได้รับการ Support อย่างน้อย 5 ปี
---
## Considered Options
### Option 1: Traditional Stack (PHP + Laravel + Vue)
**Pros:**
- ✅ Mature ecosystem
- ✅ Good documentation
- ✅ Familiar to many developers
**Cons:**
- ❌ Team ไม่มีประสบการณ์ PHP
- ❌ Separate language for frontend/backend
- ❌ Less TypeScript support
### Option 2: Java Stack (Spring Boot + React)
**Pros:**
- ✅ Enterprise-grade
- ✅ Strong typing
- ✅ Excellent tooling
**Cons:**
- ❌ Team ไม่มีประสบการณ์ Java
- ❌ Higher resource usage (QNAP limitation)
- ❌ Slower development cycle
### Option 3: **Full Stack TypeScript (NestJS + Next.js)** ⭐ (Selected)
**Pros:**
-**Single Language:** TypeScript ทั้ง Frontend และ Backend
-**Team Expertise:** ทีมมีประสบการณ์ Node.js/TypeScript
-**Modern Architecture:** Modular, Scalable, Maintainable
-**Rich Ecosystem:** NPM packages มากมาย
-**Fast Development:** Code sharing, Type safety
-**QNAP Compatible:** Docker deployment support
**Cons:**
- ❌ Runtime Performance ต่ำกว่า Java/Go
- ❌ Package.json dependency management complexity
---
## Decision Outcome
**Chosen Option:** Option 3 - Full Stack TypeScript (NestJS + Next.js)
### Selected Technologies
#### Backend Stack
| Component | Technology | Rationale |
| :----------------- | :-------------- | :--------------------------------------------- |
| **Runtime** | Node.js 20 LTS | Stable, modern features, long-term support |
| **Framework** | NestJS | Modular, TypeScript-first, similar to Angular |
| **Language** | TypeScript 5.x | Type safety, better DX |
| **ORM** | TypeORM | TypeScript support, migrations, repositories |
| **Database** | MariaDB 10.11 | JSON support, virtual columns, QNAP compatible |
| **Validation** | class-validator | Decorator-based, integrates with NestJS |
| **Authentication** | Passport + JWT | Standard, well-supported |
| **Authorization** | CASL | Flexible RBAC implementation |
| **Documentation** | Swagger/OpenAPI | Auto-generated from decorators |
| **Testing** | Jest | Built-in with NestJS |
#### Frontend Stack
| Component | Technology | Rationale |
| :-------------------- | :------------------ | :------------------------------------- |
| **Framework** | Next.js 14+ | App Router, SSR/SSG, React integration |
| **UI Library** | React 18 | Industry standard, large ecosystem |
| **Language** | TypeScript 5.x | Consistency with backend |
| **Styling** | Tailwind CSS | Utility-first, fast development |
| **Component Library** | shadcn/ui | Accessible, customizable, TypeScript |
| **State Management** | TanStack Query | Server state management |
| **Form Handling** | React Hook Form | Performance, ต้ validation with Zod |
| **Testing** | Vitest + Playwright | Fast unit tests, reliable E2E |
#### Infrastructure
| Component | Technology | Rationale |
| :------------------- | :---------------------- | :------------------------------- |
| **Containerization** | Docker + Docker Compose | QNAP Container Station |
| **Reverse Proxy** | Nginx Proxy Manager | UI-based SSL management |
| **Database** | MariaDB 10.11 | Robust, JSON support |
| **Cache** | Redis 7 | Session, locks, queue management |
| **Search** | Elasticsearch 8 | Full-text search |
| **Workflow** | n8n | Visual workflow automation |
| **Git** | Gitea | Self-hosted, lightweight |
---
## Architecture Decisions
### Backend Architecture: Modular Monolith
**Chosen:** Modular Monolith (Not Microservices)
**Rationale:**
- ✅ Easier to develop and deploy initially
- ✅ Lower infrastructure overhead (QNAP limitation)
- ✅ Simpler debugging and testing
- ✅ Can split into microservices later if needed
- ✅ Modules communicate via Event Emitters (loosely coupled)
**Module Structure:**
```
backend/src/
├── common/ # Shared utilities
├── modules/
│ ├── auth/
│ ├── user/
│ ├── project/
│ ├── correspondence/
│ ├── rfa/
│ ├── workflow-engine/
│ └── ...
```
### Frontend Architecture: Server-Side Rendering (SSR)
**Chosen:** Next.js with App Router (SSR + Client Components)
**Rationale:**
- ✅ Better SEO (if needed in future)
- ✅ Faster initial page load
- ✅ Flexibility (SSR + CSR)
- ✅ Built-in routing and API routes
- ✅ Image optimization
---
## Development Workflow
### Monorepo Structure
```
lcbp3-dms/
├── backend/ # NestJS
├── frontend/ # Next.js
├── docs/ # Documentation
├── specs/ # Specifications
└── docker-compose.yml
```
**Chosen:** Separate repositories (Not Monorepo)
**Rationale:**
- ✅ ง่ายต่อการ Deploy แยกกัน
- ✅ สิทธิ์ Git แยกได้ (Frontend team / Backend team)
- ✅ CI/CD pipeline ง่ายกว่า
- ❌ Cons: Shared types ต้องจัดการแยก
---
## Database Decisions
### ORM vs Query Builder
**Chosen:** TypeORM (ORM)
**Rationale:**
- ✅ Type-safe entities
- ✅ Migration management
- ✅ Relationship mapping
- ✅ Query Builder available when needed
- ❌ Cons: Learning curve for complex queries
### Database Choice
**Chosen:** MariaDB 10.11 (Not PostgreSQL)
**Rationale:**
- ✅ QNAP supports MariaDB หนึ่งของโจทย์
- ✅ JSON support (MariaDB 10.2+)
- ✅ Virtual columns for JSON indexing
- ✅ Familiar MySQL syntax
- ❌ Cons: ฟีเจอร์บางอย่างไม่เท่า PostgreSQL
---
## Styling Decision
### CSS Framework
**Chosen:** Tailwind CSS (Not Bootstrap, Material-UI)
**Rationale:**
- ✅ Utility-first, fast development
- ✅ Small bundle size (purge unused)
- ✅ Highly customizable
- ✅ Works well with shadcn/ui
- ✅ TypeScript autocomplete support
---
## Consequences
### Positive
1.**Single Language:** TypeScript ลด Context Switching
2.**Code Sharing:** Share types/interfaces ระหว่าง Frontend/Backend
3.**Fast Development:** Modern tooling, hot reload
4.**Type Safety:** Catch errors at compile time
5.**Rich Ecosystem:** NPM packages มากมาย
6.**Good DX:** Excellent developer experience
### Negative
1.**Runtime Performance:** ช้ากว่า Compiled languages
2.**Dependency Management:** NPM dependency hell
3.**Memory Usage:** Node.js ใช้ RAM มากกว่า PHP
4.**Package Updates:** Breaking changes บ่อย
### Mitigation Strategies
- **Performance:** ใช้ Redis caching, Database indexing
- **Dependencies:** Lock versions, use `pnpm` for deduplication
- **Memory:** Monitor และ Optimize, Set Node.js memory limits
- **Updates:** Test thoroughly before upgrading major versions
---
## Compliance
เป็นไปตาม:
- [FullStack JS Guidelines](../03-implementation/fullftack-js-v1.5.0.md)
- [Backend Guidelines](../03-implementation/backend-guidelines.md)
- [Frontend Guidelines](../03-implementation/frontend-guidelines.md)
---
## Related ADRs
- [ADR-007: Deployment Strategy](./ADR-007-deployment-strategy.md) - Docker deployment details
---
## References
- [NestJS Documentation](https://docs.nestjs.com/)
- [Next.js Documentation](https://nextjs.org/docs)
- [TypeORM Documentation](https://typeorm.io/)
- [State of JavaScript 2024](https://stateofjs.com/)

View File

@@ -0,0 +1,438 @@
# ADR-006: Redis Usage and Caching Strategy
**Status:** Accepted
**Date:** 2025-11-30
**Decision Makers:** Development Team, System Architect
**Related Documents:**
- [System Architecture](../02-architecture/system-architecture.md)
- [Performance Requirements](../01-requirements/06-non-functional.md)
---
## Context and Problem Statement
LCBP3-DMS ต้องการ High Performance ในการ:
- Check Permissions (ทุก Request)
- Document Numbering (Concurrent Safe)
- Master Data Access (ถูกเรียกบ่อยมาก)
- Session Management
- Background Job Queue
**Challenges:**
- Database queries ช้า (แม้มี indexing)
- Concurrent access ต้องมี Locking mechanism
- Permission checking ต้องเร็ว (< 10ms)
- Master data แทบไม่เปลี่ยน แต่ถูก query บ่อย
---
## Decision Drivers
- **Performance:** Response time < 200ms (p90)
- **Scalability:** รองรับ 100+ concurrent users
- **Consistency:** Data consistency with database
- **Reliability:** Cache must not cause data loss
- **Cost-Effectiveness:** ใช้ Resource น้อยที่สุด
---
## Considered Options
### Option 1: No Caching (Database Only)
**Pros:**
- ✅ Simple, no cache invalidation
- ✅ Always consistent
**Cons:**
- ❌ Slow permission checks (JOIN tables)
- ❌ High DB load
- ❌ No distributed locking
### Option 2: Application-Level In-Memory Cache
**Pros:**
- ✅ Very fast (local memory)
- ✅ No external dependency
**Cons:**
- ❌ Not shared across instances
- ❌ No distributed locking
- ❌ Cache invalidation issues
### Option 3: **Redis as Distributed Cache + Lock** ⭐ (Selected)
**Pros:**
-**Fast:** In-memory, < 1ms access
-**Distributed:** Shared across instances
-**Locking:** Redis locks for concurrency
-**Pub/Sub:** Cache invalidation broadcasting
-**Queue:** BullMQ for background jobs
**Cons:**
- ❌ External dependency
- ❌ Requires Redis cluster for HA
---
## Decision Outcome
**Chosen Option:** Redis as Distributed Cache + Lock Provider
---
## Redis Usage Patterns
### 1. Distributed Locking (Redlock)
**Use Cases:**
- Document Number Generation
- Critical Sections
**Implementation:**
```typescript
const lock = await redlock.acquire([lockKey], 3000); // 3sec TTL
try {
// Critical section
} finally {
await lock.release();
}
```
**Configuration:**
- TTL: 2-5 seconds
- Retry: Exponential backoff, max 3 retries
---
### 2. Permission Caching
**Cache Structure:**
```typescript
// Key: user:{user_id}:permissions
// Value: JSON array of CASL rules
// TTL: 30 minutes
await redis.set(
`user:${userId}:permissions`,
JSON.stringify(abilityRules),
'EX',
1800
);
```
**Invalidation Strategy:**
- Role changed → Invalidate all users with that role
- User assignment changed → Invalidate that user
- Permission modified → Invalidate all affected roles
---
### 3. Master Data Caching
**Cached Data:**
- Organizations (TTL: 1 hour)
- Projects (TTL: 1 hour)
- Correspondence Types (TTL: 24 hours)
- RFA Status Codes (TTL: 24 hours)
- Roles & Permissions (TTL: 30 minutes)
**Cache Pattern:**
```typescript
async getOrganizations(): Promise<Organization[]> {
const cacheKey = 'master:organizations';
let cached = await redis.get(cacheKey);
if (!cached) {
const organizations = await this.orgRepo.find({ where: { is_active: true } });
await redis.set(cacheKey, JSON.stringify(organizations), 'EX', 3600);
return organizations;
}
return JSON.parse(cached);
}
```
**Invalidation:**
- On CREATE/UPDATE/DELETE → Invalidate immediately
- Publish event to Redis Pub/Sub for multi-instance sync
---
### 4. Session Management
**Structure:**
```typescript
// Key: session:{session_id}
// Value: User session data
// TTL: 8 hours
interface SessionData {
user_id: number;
username: string;
organization_id: number;
last_activity: Date;
}
```
**Refresh Strategy:**
- Update `last_activity` on every request
- Extend TTL if activity within last 1 hour
---
### 5. Rate Limiting
**Implementation:**
```typescript
const key = `rate_limit:${userId}:${endpoint}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, 3600); // 1 hour window
}
if (current > limit) {
throw new TooManyRequestsException();
}
```
**Limits:**
- File Upload: 50 req/hour per user
- Search: 500 req/hour per user
- Anonymous: 100 req/hour per IP
---
### 6. Background Job Queue (BullMQ)
**Queues:**
1. **Email Queue:** Send email notifications
2. **Notification Queue:** LINE Notify
3. **Indexing Queue:** Elasticsearch indexing
4. **Cleanup Queue:** Delete temp files
5. **Report Queue:** Generate PDF reports
**Configuration:**
```typescript
const emailQueue = new Queue('email', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 100, // Keep last 100
removeOnFail: 500,
},
});
```
---
## Cache Invalidation Strategy
### 1. Time-Based Expiration (TTL)
| Data Type | TTL | Rationale |
| :------------- | :--------- | :---------------------------- |
| Permissions | 30 minutes | Balance freshness/performance |
| Master Data | 1 hour | Rarely changes |
| Session | 8 hours | Match JWT expiration |
| Search Results | 15 minutes | Data changes frequently |
### 2. Event-Based Invalidation
**Pattern:**
```typescript
@Injectable()
export class CacheInvalidationService {
async invalidateUserPermissions(userId: number): Promise<void> {
await this.redis.del(`user:${userId}:permissions`);
// Broadcast to other instances
await this.redis.publish(
'cache:invalidate',
JSON.stringify({
pattern: 'user:permissions',
userId,
})
);
}
async invalidateMasterData(entity: string): Promise<void> {
await this.redis.del(`master:${entity}`);
await this.redis.publish(
'cache:invalidate',
JSON.stringify({
pattern: 'master',
entity,
})
);
}
}
```
### 3. Write-Through Cache
**For Master Data:**
```typescript
async updateOrganization(id: number, dto: UpdateOrgDto): Promise<Organization> {
const org = await this.orgRepo.save({ id, ...dto });
// Invalidate cache immediately
await this.cache.invalidateMasterData('organizations');
return org;
}
```
---
## Redis Configuration
### Production Setup
```yaml
# docker-compose.yml
redis:
image: redis:7-alpine
command: >
redis-server
--appendonly yes
--appendfsync everysec
--maxmemory 2gb
--maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 3
```
**Key Settings:**
- `appendonly yes`: AOF persistence
- `appendfsync everysec`: Write every second (balance performance/durability)
- `maxmemory 2gb`: Limit memory usage
- `maxmemory-policy allkeys-lru`: Evict least recently used keys
---
### High Availability Considerations
**Future Improvements:**
1. **Redis Sentinel:** Auto-failover
2. **Redis Cluster:** Horizontal scaling
3. **Read Replicas:** Offload read queries
**Current:** Single Redis instance (sufficient for MVP)
---
## Monitoring
### Key Metrics
```typescript
@Injectable()
export class RedisMonitoringService {
@Cron('*/5 * * * *') // Every 5 minutes
async captureMetrics(): Promise<void> {
const info = await this.redis.info();
// Parse and log metrics
metrics.record({
'redis.memory.used': parseMemoryUsed(info),
'redis.memory.peak': parseMemoryPeak(info),
'redis.keyspace.hits': parseHits(info),
'redis.keyspace.misses': parseMisses(info),
'redis.connections.active': parseConnections(info),
});
}
}
```
**Alert Thresholds:**
- Memory usage > 80%
- Hit rate < 70%
- Connections > 90% of max
---
## Consequences
### Positive
1.**Fast Permission Check:** < 5ms (vs 50ms from DB)
2.**Reduced DB Load:** 70% reduction in queries
3.**Distributed Locking:** No race conditions
4.**Queue Management:** Background jobs reliable
5.**Scalability:** รองรับ Multi-instance deployment
### Negative
1.**Dependency:** Redis ต้อง Available เสมอ
2.**Memory Limit:** ต้อง Monitor และ Evict
3.**Complexity:** Cache invalidation ซับซ้อน
4.**Data Loss Risk:** ถ้า Redis crash (with AOF mitigates this)
### Mit Strategies
- **Dependency:** Health checks + Fallback to DB
- **Memory:** Set max memory + LRU eviction policy
- **Complexity:** Centralize invalidation logic
- **Data Loss:** Enable AOF persistence
---
## Compliance
เป็นไปตาม:
- [System Architecture Section 3.5](../02-architecture/system-architecture.md#redis)
- [Performance Requirements](../01-requirements/06-non-functional.md)
---
## Related ADRs
- [ADR-002: Document Numbering Strategy](./ADR-002-document-numbering-strategy.md) - Redis locks
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md) - Permission caching
---
## References
- [Redis Documentation](https://redis.io/docs/)
- [Redlock Algorithm](https://redis.io/topics/distlock)
- [BullMQ Documentation](https://docs.bullmq.io/)
- [Cache Invalidation Strategies](https://martinfowler.com/bliki/TwoHardThings.html)

View File

@@ -0,0 +1,352 @@
# ADR-007: API Design & Error Handling Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Backend Team, System Architect
**Related Documents:** [Backend Guidelines](../03-implementation/backend-guidelines.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
---
## Context and Problem Statement
ระบบ LCBP3-DMS ต้องการมาตรฐานการออกแบบ API ที่ชัดเจนและสม่ำเสมอทั้งระบบ รวมถึงกลยุทธ์การจัดการ Error และ Validation ที่เหมาะสม
### ปัญหาที่ต้องแก้:
1. **API Consistency:** ทำอย่างไรให้ API response format สม่ำเสมอทั้งระบบ
2. **Error Handling:** จัดการ error อย่างไรให้ client เข้าใจและแก้ไขได้
3. **Validation:** Validate request อย่างไรให้ครอบคลุมและให้ feedback ที่ดี
4. **Status Codes:** ใช้ HTTP status codes อย่างไรให้ถูกต้องและสม่ำเสมอ
---
## Decision Drivers
- 🎯 **Developer Experience:** Frontend developers ต้องใช้ API ได้ง่าย
- 🔒 **Security:** ป้องกัน Information Leakage จาก Error messages
- 📊 **Debuggability:** ต้องหา Root cause ของ Error ได้ง่าย
- 🌍 **Internationalization:** รองรับภาษาไทยและอังกฤษ
- 📝 **Standards Compliance:** ใช้มาตรฐานที่เป็นที่ยอมรับ (REST, JSON:API)
---
## Considered Options
### Option 1: Standard REST with Custom Error Format
**รูปแบบ:**
```typescript
// Success
{
"data": { ... },
"meta": { "timestamp": "..." }
}
// Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [...]
}
}
```
**Pros:**
- ✅ Simple และเข้าใจง่าย
- ✅ Flexible สำหรับ Custom needs
- ✅ ไม่ต้อง Follow spec ที่ซับซ้อน
**Cons:**
- ❌ ไม่มี Standard specification
- ❌ ต้องสื่อสารภายในทีมให้ชัดเจน
- ❌ อาจไม่สม่ำเสมอหากไม่ระวัง
### Option 2: JSON:API Specification
**รูปแบบ:**
```typescript
{
"data": {
"type": "correspondences",
"id": "1",
"attributes": { ... },
"relationships": { ... }
},
"included": [...]
}
```
**Pros:**
- ✅ มาตรฐานที่เป็นที่ยอมรับ
- ✅ มี Libraries ช่วย
- ✅ รองรับ Relationships ได้ดี
**Cons:**
- ❌ ซับซ้อนเกินความจำเป็น
- ❌ Verbose (ข้อมูลซ้ำซ้อน)
- ❌ Learning curve สูง
### Option 3: GraphQL
**Pros:**
- ✅ Client เลือกข้อมูลที่ต้องการได้
- ✅ ลด Over-fetching/Under-fetching
- ✅ Strong typing
**Cons:**
- ❌ Complexity สูง
- ❌ Caching ยาก
- ❌ ไม่เหมาะกับ Document-heavy system
- ❌ Team ยังไม่มีประสบการณ์
---
## Decision Outcome
**Chosen Option:** **Option 1 - Standard REST with Custom Error Format + NestJS Exception Filters**
### Rationale
1. **Simplicity:** ทีมคุ้นเคยกับ REST API และ NestJS มี Built-in support ที่ดี
2. **Flexibility:** สามารถปรับแต่งตาม Business needs ได้ง่าย
3. **Performance:** Lightweight กว่า JSON:API และ GraphQL
4. **Team Capability:** ทีมมีประสบการณ์ REST มากกว่า GraphQL
---
## Implementation Details
### 1. Success Response Format
```typescript
// Single resource
{
"data": {
"id": 1,
"document_number": "CORR-2024-0001",
"subject": "...",
...
},
"meta": {
"timestamp": "2024-01-01T00:00:00Z",
"version": "1.0"
}
}
// Collection with pagination
{
"data": [
{ "id": 1, ... },
{ "id": 2, ... }
],
"meta": {
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
},
"timestamp": "2024-01-01T00:00:00Z"
}
}
```
### 2. Error Response Format
```typescript
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed on input data",
"statusCode": 400,
"timestamp": "2024-01-01T00:00:00Z",
"path": "/api/correspondences",
"details": [
{
"field": "subject",
"message": "Subject is required",
"value": null
}
]
}
}
```
### 3. HTTP Status Codes
| Status | Use Case |
| ------------------------- | ------------------------------------------- |
| 200 OK | Successful GET, PUT, PATCH |
| 201 Created | Successful POST |
| 204 No Content | Successful DELETE |
| 400 Bad Request | Validation error, Invalid input |
| 401 Unauthorized | Missing or invalid JWT token |
| 403 Forbidden | Insufficient permissions (RBAC) |
| 404 Not Found | Resource not found |
| 409 Conflict | Duplicate resource, Business rule violation |
| 422 Unprocessable Entity | Business logic error |
| 429 Too Many Requests | Rate limit exceeded |
| 500 Internal Server Error | Unexpected server error |
### 4. Global Exception Filter
```typescript
// File: backend/src/common/filters/global-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code = 'INTERNAL_SERVER_ERROR';
let message = 'An unexpected error occurred';
let details = null;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object') {
code = (exceptionResponse as any).error || exception.name;
message = (exceptionResponse as any).message || exception.message;
details = (exceptionResponse as any).details;
} else {
message = exceptionResponse;
}
}
// Log error (but don't expose internal details to client)
console.error('Exception:', exception);
response.status(status).json({
error: {
code,
message,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
...(details && { details }),
},
});
}
}
```
### 5. Custom Business Exception
```typescript
// File: backend/src/common/exceptions/business.exception.ts
export class BusinessException extends HttpException {
constructor(message: string, code: string = 'BUSINESS_ERROR') {
super(
{
error: code,
message,
},
HttpStatus.UNPROCESSABLE_ENTITY
);
}
}
// Usage
throw new BusinessException(
'Cannot approve correspondence in current status',
'INVALID_WORKFLOW_TRANSITION'
);
```
### 6. Validation Pipe Configuration
```typescript
// File: backend/src/main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties not in DTO
forbidNonWhitelisted: true, // Throw error if unknown properties
transform: true, // Auto-transform payloads to DTO instances
transformOptions: {
enableImplicitConversion: true,
},
exceptionFactory: (errors) => {
const details = errors.map((error) => ({
field: error.property,
message: Object.values(error.constraints || {}).join(', '),
value: error.value,
}));
return new HttpException(
{
error: 'VALIDATION_ERROR',
message: 'Validation failed',
details,
},
HttpStatus.BAD_REQUEST
);
},
})
);
```
---
## Consequences
### Positive Consequences
1.**Consistency:** API responses มีรูปแบบสม่ำเสมอทั้งระบบ
2.**Developer Friendly:** Frontend developers ใช้งาน API ได้ง่าย
3.**Debuggability:** Error messages ให้ข้อมูลเพียงพอสำหรับ Debug
4.**Security:** ไม่เปิดเผย Internal error details ให้ Client
5.**Maintainability:** ใช้ NestJS built-in features ทำให้ Maintain ง่าย
### Negative Consequences
1.**No Standard Spec:** ไม่ใช่ Standard เช่น JSON:API จึงต้องเขียน Documentation ชัดเจน
2.**Manual Documentation:** ต้อง Document API response format เอง
3.**Learning Curve:** Team members ใหม่ต้องเรียนรู้ Error code conventions
### Mitigation Strategies
- **Documentation:** ใช้ Swagger/OpenAPI เพื่อ Auto-generate API docs
- **Code Generation:** Generate TypeScript interfaces สำหรับ Frontend จาก DTOs
- **Error Code Registry:** มี Centralized list ของ Error codes พร้อมคำอธิบาย
- **Testing:** เขียน Integration tests เพื่อ Validate response formats
---
## Related ADRs
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ NestJS
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md) - Error 403 Forbidden
---
## References
- [NestJS Exception Filters](https://docs.nestjs.com/exception-filters)
- [HTTP Status Codes](https://httpstatuses.com/)
- [REST API Best Practices](https://restfulapi.net/)
---
**Last Updated:** 2025-12-01
**Next Review:** 2025-06-01

View File

@@ -0,0 +1,388 @@
# ADR-008: Email & Notification Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Backend Team, System Architect
**Related Documents:** [Backend Guidelines](../03-implementation/backend-guidelines.md), [TASK-BE-011](../06-tasks/TASK-BE-011-notification-audit.md)
---
## Context and Problem Statement
ระบบ LCBP3-DMS ต้องการส่งการแจ้งเตือนให้ผู้ใช้งานผ่านหลายช่องทาง (Email, LINE Notify, In-App) เมื่อมี Events สำคัญเกิดขึ้น เช่น Correspondence ได้รับการอนุมัติ, RFA ถูก Review, Workflow เปลี่ยนสถานะ
### ปัญหาที่ต้องแก้:
1. **Multi-Channel:** รองรับหลายช่องทางการแจ้งเตือน (Email, LINE, In-app)
2. **Reliability:** ทำอย่างไรให้การส่ง Email ไม่ Block main request
3. **Retry Logic:** จัดการ Email delivery failures อย่างไร
4. **Template Management:** จัดการ Email templates อย่างไรให้ Maintainable
5. **User Preferences:** ให้ User เลือก Channel ที่ต้องการได้อย่างไร
---
## Decision Drivers
-**Performance:** ส่ง Email ต้องไม่ทำให้ API Response ช้า
- 🔄 **Reliability:** Email ส่งไม่สำเร็จต้อง Retry ได้
- 🎨 **Branding:** Email template ต้องดูเป็นมืออาชีพ
- 🛠️ **Maintainability:** แก้ไข Template ได้ง่าย
- 📱 **Multi-Channel:** รองรับ Email, LINE, In-app notification
---
## Considered Options
### Option 1: Sync Email Sending (ส่งทันที ใน Request)
**Implementation:**
```typescript
await this.emailService.sendEmail({ to, subject, body });
return { success: true };
```
**Pros:**
- ✅ Simple implementation
- ✅ ง่ายต่อการ Debug
**Cons:**
- ❌ Block API response (slow)
- ❌ หาก SMTP server down จะ Timeout
- ❌ ไม่มี Retry mechanism
### Option 2: Async with Event Emitter (NestJS EventEmitter)
**Implementation:**
```typescript
this.eventEmitter.emit('correspondence.approved', { correspondenceId });
// Return immediately
return { success: true };
// Listener
@OnEvent('correspondence.approved')
async handleApproved(payload) {
await this.emailService.sendEmail(...);
}
```
**Pros:**
- ✅ Non-blocking (async)
- ✅ Decoupled
**Cons:**
- ❌ ไม่มี Retry หาก Event listener fail
- ❌ Lost jobs หาก Server restart
### Option 3: Message Queue (BullMQ + Redis)
**Implementation:**
```typescript
await this.emailQueue.add('send-email', {
to,
subject,
template,
context,
});
```
**Pros:**
- ✅ Non-blocking (async)
- ✅ Persistent (Store in Redis)
- ✅ Built-in Retry mechanism
- ✅ Job monitoring & management
- ✅ Scalable (Multiple workers)
**Cons:**
- ❌ Requires Redis infrastructure
- ❌ More complex setup
---
## Decision Outcome
**Chosen Option:** **Option 3 - Message Queue (BullMQ + Redis)**
### Rationale
1. **Performance:** ไม่ Block API response, ส่ง Email แบบ Async
2. **Reliability:** Persistent jobs ใน Redis, มี Retry mechanism
3. **Scalability:** สามารถ Scale workers แยกได้
4. **Monitoring:** ดู Job status, Failed jobs ได้
5. **Infrastructure:** Redis มีอยู่แล้วสำหรับ Locking และ Caching (ADR-006)
---
## Implementation Details
### 1. Email Queue Setup
```typescript
// File: backend/src/modules/notification/notification.module.ts
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.registerQueue({
name: 'email',
connection: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
},
}),
BullModule.registerQueue({
name: 'line-notify',
}),
],
providers: [NotificationService, EmailProcessor, LineNotifyProcessor],
})
export class NotificationModule {}
```
### 2. Queue Email Job
```typescript
// File: backend/src/modules/notification/notification.service.ts
@Injectable()
export class NotificationService {
constructor(
@InjectQueue('email') private emailQueue: Queue,
@InjectQueue('line-notify') private lineQueue: Queue
) {}
async sendEmailNotification(dto: SendEmailDto): Promise<void> {
await this.emailQueue.add(
'send-email',
{
to: dto.to,
subject: dto.subject,
template: dto.template, // e.g., 'correspondence-approved'
context: dto.context, // Template variables
},
{
attempts: 3, // Retry 3 times
backoff: {
type: 'exponential',
delay: 5000, // Start with 5s delay
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24h
},
removeOnFail: false, // Keep failed jobs for debugging
}
);
}
async sendLineNotification(dto: SendLineDto): Promise<void> {
await this.lineQueue.add('send-line', {
token: dto.token,
message: dto.message,
});
}
}
```
### 3. Email Processor (Worker)
```typescript
// File: backend/src/modules/notification/processors/email.processor.ts
import { Processor, Process } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import * as nodemailer from 'nodemailer';
import * as handlebars from 'handlebars';
import * as fs from 'fs/promises';
@Processor('email')
export class EmailProcessor {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
@Process('send-email')
async handleSendEmail(job: Job) {
const { to, subject, template, context } = job.data;
try {
// Load and compile template
const templatePath = `./templates/emails/${template}.hbs`;
const templateSource = await fs.readFile(templatePath, 'utf-8');
const compiledTemplate = handlebars.compile(templateSource);
const html = compiledTemplate(context);
// Send email
const info = await this.transporter.sendMail({
from: process.env.SMTP_FROM,
to,
subject,
html,
});
console.log('Email sent:', info.messageId);
return info;
} catch (error) {
console.error('Failed to send email:', error);
throw error; // Will trigger retry
}
}
}
```
### 4. Email Template (Handlebars)
```handlebars
<!-- File: backend/templates/emails/correspondence-approved.hbs -->
<html>
<head>
<style>
body { font-family: Arial, sans-serif; } .container { max-width: 600px;
margin: 0 auto; padding: 20px; } .header { background: #007bff; color:
white; padding: 20px; } .content { padding: 20px; } .button { background:
#007bff; color: white; padding: 10px 20px; text-decoration: none; }
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h1>Correspondence Approved</h1>
</div>
<div class='content'>
<p>สวัสดีคุณ {{userName}},</p>
<p>เอกสาร <strong>{{documentNumber}}</strong> ได้รับการอนุมัติแล้ว</p>
<p><strong>Subject:</strong> {{subject}}</p>
<p><strong>Approved by:</strong> {{approver}}</p>
<p><strong>Date:</strong> {{approvedDate}}</p>
<p>
<a href='{{documentUrl}}' class='button'>ดูเอกสาร</a>
</p>
</div>
</div>
</body>
</html>
```
### 5. Workflow Event → Email Notification
```typescript
// File: backend/src/modules/workflow/workflow.service.ts
async executeTransition(workflowId: number, action: string) {
// ... Execute transition logic
// Send notifications
await this.notificationService.notifyWorkflowTransition(
workflowId,
action,
currentUserId,
);
}
```
```typescript
// File: backend/src/modules/notification/notification.service.ts
async notifyWorkflowTransition(
workflowId: number,
action: string,
actorId: number,
) {
// Get users to notify
const users = await this.getRelevantUsers(workflowId);
for (const user of users) {
// In-app notification
await this.createNotification({
user_id: user.user_id,
type: 'workflow_transition',
title: `${action} completed`,
message: `Workflow ${workflowId} has been ${action}`,
link: `/workflows/${workflowId}`,
});
// Email (if enabled)
if (user.email_notifications_enabled) {
await this.sendEmailNotification({
to: user.email,
subject: `Workflow Update: ${action}`,
template: 'workflow-transition',
context: {
userName: user.first_name,
action,
workflowId,
documentUrl: `${process.env.FRONTEND_URL}/workflows/${workflowId}`,
},
});
}
// LINE Notify (if enabled)
if (user.line_notify_token) {
await this.sendLineNotification({
token: user.line_notify_token,
message: `[LCBP3-DMS] Workflow ${workflowId}: ${action}`,
});
}
}
}
```
---
## Consequences
### Positive Consequences
1.**Performance:** API responses ไม่ถูก Block โดยการส่ง Email
2.**Reliability:** Jobs ถูกเก็บใน Redis ไม่สูญหายหาก Server restart
3.**Retry:** Automatic retry สำหรับ Failed jobs
4.**Monitoring:** ดู Job status, Failed jobs ผ่าน Bull Board
5.**Scalability:** เพิ่ม Email workers ได้ตามต้องการ
6.**Multi-Channel:** รองรับ Email, LINE, In-app notification
### Negative Consequences
1.**Delayed Delivery:** Email ส่งแบบ Async อาจมี Delay เล็กน้อย
2.**Dependency on Redis:** หาก Redis down ก็ส่ง Email ไม่ได้
3.**Template Management:** ต้อง Maintain Handlebars templates แยก
### Mitigation Strategies
- **Redis Monitoring:** ตั้ง Alert หาก Redis down
- **Template Versioning:** เก็บ Email templates ใน Git
- **Fallback:** หาก Redis ล้ม อาจ Fallback เป็น Sync sending ชั่วคราว
- **Testing:** ใช้ Mailtrap/MailHog สำหรับ Testing ใน Development
---
## Related ADRs
- [ADR-006: Redis Caching Strategy](./ADR-006-redis-caching-strategy.md) - ใช้ Redis สำหรับ Queue
- [TASK-BE-011: Notification & Audit](../06-tasks/TASK-BE-011-notification-audit.md)
---
## References
- [BullMQ Documentation](https://docs.bullmq.io/)
- [Nodemailer Documentation](https://nodemailer.com/)
- [Handlebars Documentation](https://handlebarsjs.com/)
---
**Last Updated:** 2025-12-01
**Next Review:** 2025-06-01

View File

@@ -0,0 +1,383 @@
# ADR-009: Database Migration & Deployment Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Backend Team, DevOps Team, System Architect
**Related Documents:** [TASK-BE-001](../06-tasks/TASK-BE-001-database-migrations.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
---
## Context and Problem Statement
ระบบ LCBP3-DMS ต้องการกลยุทธ์การจัดการ Database Schema และ Data migrations ที่ปลอดภัยและเชื่อถือได้ เพื่อให้ Deploy ใหม่ได้โดยไม่ทำให้ Production data เสียหาย
### ปัญหาที่ต้องแก้:
1. **Schema Evolution:** จัดการการเปลี่ยนแปลง Schema ใน Production อย่างไร
2. **Zero-Downtime:** Deploy โดยไม่ต้อง Downtime ระบบได้หรือไม่
3. **Rollback:** หาก Migration ล้มเหลว จะ Rollback อย่างไร
4. **Data Safety:** ป้องกัน Data loss จาก Migration errors อย่างไร
5. **Team Collaboration:** หลายคน Develop พร้อมกัน จัดการ Migration conflicts อย่างไร
---
## Decision Drivers
- 🔒 **Data Safety:** ป้องกัน Data loss เป็นอันดับแรก
-**Zero Downtime:** Deploy ได้โดยไม่ต้อง Stop service
- 🔄 **Reversibility:** สามารถ Rollback ได้ถ้า Migration ล้ม
- 👥 **Team Collaboration:** หลายคน Work พร้อมกัน ไม่ Conflict
- 📊 **Auditability:** ต้องรู้ว่า Schema เป็น Version ไหน
---
## Considered Options
### Option 1: Synchronize Schema (TypeORM synchronize: true)
**Implementation:**
```typescript
TypeOrmModule.forRoot({
synchronize: true, // Auto-generate schema from entities
});
```
**Pros:**
- ✅ ง่ายที่สุด ไม่ต้องเขียน Migration
- ✅ เหมาะสำหรับ Development
**Cons:**
-**อันตราย** ใน Production (อาจ Drop columns/tables)
- ❌ ไม่มี Version control
- ❌ ไม่มี Rollback
- ❌ ไม่เหมาะสำหรับ Production
### Option 2: Manual SQL Scripts
**Implementation:**
- เขียน SQL scripts ด้วยมือ
- Execute โดย DBA
**Pros:**
- ✅ Full control
- ✅ Review ได้ละเอียด
**Cons:**
- ❌ Manual process (Error-prone)
- ❌ ไม่มี Automation
- ❌ ลืม Run migration ได้
- ❌ Tracking ยาก
### Option 3: TypeORM Migrations (Automated + Version Controlled)
**Implementation:**
```bash
npm run migration:generate -- MigrationName
npm run migration:run
npm run migration:revert
```
**Pros:**
- ✅ Version controlled (Git)
- ✅ Automatic tracking (`migrations` table)
- ✅ Rollback support
- ✅ Generated from Entity changes
- ✅ CI/CD integration
**Cons:**
- ❌ ต้องเขียน Migration files
- ❌ Requires discipline
---
## Decision Outcome
**Chosen Option:** **Option 3 - TypeORM Migrations + Blue-Green Deployment Strategy**
### Rationale
1. **Safety:** Migrations มี Version control และ Rollback mechanism
2. **Automation:** Run migrations auto ใน CI/CD pipeline
3. **Tracking:** ดู Migration history ได้จาก `migrations` table
4. **Team Collaboration:** Merge migrations ใน Git เหมือน Code
5. **Zero Downtime:** ใช้ Blue-Green deployment สำหรับ Breaking changes
---
## Implementation Details
### 1. TypeORM Configuration
```typescript
// File: backend/src/config/database.config.ts
export default {
type: 'mariadb',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
migrationsTableName: 'migrations',
synchronize: false, // NEVER true in production
logging: process.env.NODE_ENV === 'development',
};
```
### 2. Migration Workflow
```bash
# 1. Create new entity or modify existing
# 2. Generate migration
npm run migration:generate -- -n AddDisciplineIdToCorrespondences
# Output: src/migrations/1234567890-AddDisciplineIdToCorrespondences.ts
# 3. Review generated migration
# 4. Test migration locally
npm run migration:run
# 5. Test rollback
npm run migration:revert
# 6. Commit to Git
git add src/migrations/
git commit -m "feat: add discipline_id to correspondences"
```
### 3. Migration File Example
```typescript
// File: backend/src/migrations/1234567890-AddDisciplineIdToCorrespondences.ts
import {
MigrationInterface,
QueryRunner,
TableColumn,
TableForeignKey,
} from 'typeorm';
export class AddDisciplineIdToCorrespondences1234567890
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
// Add column
await queryRunner.addColumn(
'correspondences',
new TableColumn({
name: 'discipline_id',
type: 'int',
isNullable: true,
})
);
// Add foreign key
await queryRunner.createForeignKey(
'correspondences',
new TableForeignKey({
columnNames: ['discipline_id'],
referencedTableName: 'disciplines',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
})
);
// Add index
await queryRunner.query(
'CREATE INDEX idx_correspondences_discipline_id ON correspondences(discipline_id)'
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Reverse order: index → FK → column
await queryRunner.query(
'DROP INDEX idx_correspondences_discipline_id ON correspondences'
);
const table = await queryRunner.getTable('correspondences');
const foreignKey = table.foreignKeys.find(
(fk) => fk.columnNames.indexOf('discipline_id') !== -1
);
await queryRunner.dropForeignKey('correspondences', foreignKey);
await queryRunner.dropColumn('correspondences', 'discipline_id');
}
}
```
### 4. CI/CD Pipeline Integration
```yaml
# File: .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Run migrations
run: npm run migration:run
env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_PORT: ${{ secrets.DB_PORT }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASS: ${{ secrets.DB_PASS }}
DB_NAME: ${{ secrets.DB_NAME }}
- name: Deploy application
run: |
# Deploy to server
pm2 restart app
```
### 5. Zero-Downtime Migration Strategy
**กรณี Non-Breaking Changes (เพิ่ม Column ใหม่):**
```bash
# Step 1: Add nullable column (Old code still works)
ALTER TABLE correspondences ADD COLUMN discipline_id INT NULL;
# Step 2: Deploy new code (Can use new column)
pm2 restart app
# Step 3: (Optional) Backfill data if needed
UPDATE correspondences SET discipline_id = X WHERE ...;
```
**กรณี Breaking Changes (ลบ Column, เปลี่ยนชนิด):**
**Blue-Green Deployment:**
```bash
# Step 1: Deploy "Green" (New version) พร้อม Migration
# - Database supports ทั้ง old + new schema
# - Run migration: Add new column, Keep old column
# Step 2: Route traffic to "Green"
# - Load balancer switches to new version
# Step 3: Verify "Green" works
# - Monitor errors, metrics
# Step 4: (After 24h) Cleanup old schema
# - Run migration: Drop old column
# - Shutdown "Blue" (Old version)
```
### 6. Migration Testing
```typescript
// File: backend/test/migrations/migration.spec.ts
describe('Migrations', () => {
let dataSource: DataSource;
beforeEach(async () => {
dataSource = await createTestDataSource();
});
it('should run all migrations successfully', async () => {
await dataSource.runMigrations();
// Verify tables exist
const tables = await dataSource.query('SHOW TABLES');
expect(tables).toContainEqual(
expect.objectContaining({ Tables_in_lcbp3: 'correspondences' })
);
});
it('should rollback all migrations successfully', async () => {
await dataSource.runMigrations();
await dataSource.undoLastMigration();
// Verify rollback worked
});
});
```
---
## Consequences
### Positive Consequences
1.**Version Control:** Migrations อยู่ใน Git มี History
2.**Automation:** CI/CD run migrations automatically
3.**Rollback:** สามารถ Revert migration ได้
4.**Audit Trail:** ดู Migration history ใน `migrations` table
5.**Zero Downtime:** สามารถ Deploy โดยไม่ Downtime (Blue-Green)
### Negative Consequences
1.**Discipline Required:** ต้อง Review migrations ก่อน Merge
2.**Complex Rollbacks:** Breaking changes ยาก Rollback
3.**Migration Conflicts:** หลายคน Develop อาจ Conflict (แก้ด้วย Rebase)
### Mitigation Strategies
- **Code Review:** Review migrations เหมือน Code
- **Testing:** Test migrations ใน Staging ก่อน Production
- **Backup:** Backup database ก่อน Run migration ใน Production
- **Monitoring:** Monitor migration execution time และ Errors
- **Documentation:** Document Breaking changes ชัดเจน
---
## Migration Best Practices
### DO:
- ✅ Test migrations ใน Development และ Staging
- ✅ Backup database ก่อน Production migration
- ✅ ใช้ Transactions (TypeORM มีอัตโนมัติ)
- ✅ เขียน `down()` migration สำหรับ Rollback
- ✅ ใช้ Nullable columns สำหรับ Non-breaking changes
### DON'T:
- ❌ Run `synchronize: true` ใน Production
- ❌ ลบ Column/Table โดยไม่ Deploy code ก่อน
- ❌ เปลี่ยน Data type โดยตรง (ใช้ New column แทน)
- ❌ Hardcode Values ใน Migration (ใช้ Environment variables)
---
## Related ADRs
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ TypeORM
- [TASK-BE-001: Database Migrations](../06-tasks/TASK-BE-001-database-migrations.md)
---
## References
- [TypeORM Migrations](https://typeorm.io/migrations)
- [Blue-Green Deployment](https://martinfowler.com/bliki/BlueGreenDeployment.html)
- [Zero-Downtime Migrations](https://www.braintreepayments.com/blog/safe-operations-for-high-volume-postgresql/)
---
**Last Updated:** 2025-12-01
**Next Review:** 2025-06-01

View File

@@ -0,0 +1,464 @@
# ADR-010: Logging & Monitoring Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Backend Team, DevOps Team
**Related Documents:** [Backend Guidelines](../03-implementation/backend-guidelines.md)
---
## Context and Problem Statement
ระบบ LCBP3-DMS ต้องการ Logging และ Monitoring ที่ดีเพื่อ:
- Debug ปัญหาใน Production
- ติดตาม Performance metrics
- Audit trail สำหรับ Security และ Compliance
- Alert เมื่อมี Errors หรือ Anomalies
### ปัญหาที่ต้องแก้:
1. **Structured Logging:** บันทึก Logs ในรูปแบบที่ค้นหาและวิเคราะห์ได้ง่าย
2. **Log Levels:** กำหนด Log levels ที่เหมาะสมสำหรับแต่ละสถานการณ์
3. **Performance Monitoring:** ติดตาม Response time, Database queries, Memory usage
4. **Error Tracking:** ติดตาม Errors และ Exceptions อย่างเป็นระบบ
5. **Centralized Logging:** รวม Logs จากหลาย Services ไว้ที่เดียว
---
## Decision Drivers
- 🔍 **Debuggability:** หา Root cause ของปัญหาได้เร็ว
- 📊 **Performance Insights:** ดู Metrics และ Bottlenecks
- 🚨 **Alerting:** แจ้งเตือนเมื่อมีปัญหา
- 📈 **Scalability:** รองรับ High-volume logs
- 💰 **Cost:** ไม่ต้องลงทุนมากในช่วงเริ่มต้น
---
## Considered Options
### Option 1: Console.log (Built-in)
**Pros:**
- ✅ Simple, ไม่ต้อง Setup
- ✅ ไม่มีค่าใช้จ่าย
**Cons:**
- ❌ ไม่มี Structure
- ❌ ไม่มี Log levels
- ❌ ไม่มี Log rotation
- ❌ ยากต่อการ Search/Filter
### Option 2: Winston (Structured Logging Library)
**Pros:**
- ✅ Structured logs (JSON format)
- ✅ Multiple transports (File, Console, HTTP)
- ✅ Log levels (error, warn, info, debug)
- ✅ Log rotation
- ✅ Mature library
**Cons:**
- ❌ ต้อง Configure transports
- ❌ Performance overhead (minimal)
### Option 3: Full Observability Stack (ELK/Datadog/New Relic)
**Pros:**
- ✅ Complete solution (Logs + Metrics + APM)
- ✅ Powerful query และ Visualization
- ✅ Built-in Alerting
**Cons:**
-**ค่าใช้จ่ายสูง**
- ❌ Complex setup
- ❌ Overkill สำหรับ MVP
---
## Decision Outcome
**Chosen Option:** **Option 2 (Winston) + Docker Logging + Future ELK Stack**
### Rationale
**Phase 1 (MVP):** Winston with File/Console outputs
- ✅ เพียงพอสำหรับ MVP
- ✅ Structured logs พร้อมสำหรับ ELK ในอนาคต
- ✅ ไม่มีค่าใช้จ่ายเพิ่ม
**Phase 2 (Production Scale):** Add ELK Stack (Elasticsearch, Logstash, Kibana)
- ✅ Centralized logging
- ✅ Search และ Visualization
- ✅ Open-source (ไม่มี Vendor lock-in)
---
## Implementation Details
### 1. Winston Configuration
```typescript
// File: backend/src/config/logger.config.ts
import * as winston from 'winston';
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
);
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
defaultMeta: {
service: 'lcbp3-dms-backend',
environment: process.env.NODE_ENV,
},
transports: [
// Console output (for Development)
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
return `${timestamp} [${level}]: ${message} ${
Object.keys(meta).length ? JSON.stringify(meta) : ''
}`;
})
),
}),
// File output (for Production)
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 10485760, // 10MB
maxFiles: 5,
}),
new winston.transports.File({
filename: 'logs/combined.log',
maxsize: 10485760,
maxFiles: 10,
}),
],
});
```
### 2. NestJS Logger Integration
```typescript
// File: backend/src/main.ts
import { Logger } from '@nestjs/common';
import { logger as winstonLogger } from './config/logger.config';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: new WinstonLogger(winstonLogger),
});
// ...
}
```
### 3. Custom Winston Logger for NestJS
```typescript
// File: backend/src/common/logger/winston.logger.ts
import { LoggerService } from '@nestjs/common';
import { Logger as WinstonLoggerType } from 'winston';
export class WinstonLogger implements LoggerService {
constructor(private readonly logger: WinstonLoggerType) {}
log(message: string, context?: string) {
this.logger.info(message, { context });
}
error(message: string, trace?: string, context?: string) {
this.logger.error(message, { trace, context });
}
warn(message: string, context?: string) {
this.logger.warn(message, { context });
}
debug(message: string, context?: string) {
this.logger.debug(message, { context });
}
verbose(message: string, context?: string) {
this.logger.verbose(message, { context });
}
}
```
### 4. Request Logging Middleware
```typescript
// File: backend/src/common/middleware/request-logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { logger } from 'src/config/logger.config';
@Injectable()
export class RequestLoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info('HTTP Request', {
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration: `${duration}ms`,
userAgent: req.headers['user-agent'],
ip: req.ip,
userId: (req as any).user?.user_id,
});
});
next();
}
}
```
### 5. Database Query Logging
```typescript
// File: backend/src/config/database.config.ts
export default {
// ...
logging:
process.env.NODE_ENV === 'development'
? 'all'
: ['error', 'warn', 'schema'],
logger: 'advanced-console',
maxQueryExecutionTime: 1000, // Warn if query > 1s
};
```
### 6. Error Logging in Exception Filter
```typescript
// File: backend/src/common/filters/global-exception.filter.ts
import { logger } from 'src/config/logger.config';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
// ... get status, message
// Log error
logger.error('Exception occurred', {
error: exception,
statusCode: status,
path: request.url,
method: request.method,
userId: request.user?.user_id,
stack: exception instanceof Error ? exception.stack : null,
});
// Send response to client
response.status(status).json({ ... });
}
}
```
### 7. Log Levels Usage
```typescript
// ERROR: จับ Exceptions และ Errors
logger.error('Failed to create correspondence', { error, userId, documentId });
// WARN: สถานการณ์ผิดปกติ แต่ไม่ Error
logger.warn('Document numbering retry attempt 2/3', { template, counter });
// INFO: Business events สำคัญ
logger.info('Correspondence approved', { documentId, approvedBy });
// DEBUG: ข้อมูลละเอียดสำหรับ Development
logger.debug('Workflow transition guard check', { workflowId, guardResult });
// VERBOSE: ข้อมูลละเอียดมากๆ
logger.verbose('Cache hit', { key, ttl });
```
### 8. Performance Monitoring
```typescript
// File: backend/src/common/interceptors/performance.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { logger } from 'src/config/logger.config';
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
if (duration > 1000) {
logger.warn('Slow request detected', {
method: request.method,
url: request.url,
duration: `${duration}ms`,
});
}
})
);
}
}
```
---
## Log Format Example
### Development (Console)
```
2024-01-01 10:30:15 [info]: Correspondence approved { documentId: 123, approvedBy: 5 }
2024-01-01 10:30:16 [error]: Failed to send email { error: 'SMTP timeout', userId: 5 }
```
### Production (JSON File)
```json
{
"timestamp": "2024-01-01T10:30:15.123Z",
"level": "info",
"message": "Correspondence approved",
"service": "lcbp3-dms-backend",
"environment": "production",
"documentId": 123,
"approvedBy": 5
}
```
---
## Future: ELK Stack Integration
**Phase 2 Setup:**
```yaml
# docker-compose.yml
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
environment:
- discovery.type=single-node
ports:
- '9200:9200'
logstash:
image: docker.elastic.co/logstash/logstash:8.11.0
volumes:
- ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf
depends_on:
- elasticsearch
kibana:
image: docker.elastic.co/kibana/kibana:8.11.0
ports:
- '5601:5601'
depends_on:
- elasticsearch
```
**Winston transport to Logstash:**
```typescript
import { LogstashTransport } from 'winston-logstash';
logger.add(
new LogstashTransport({
host: process.env.LOGSTASH_HOST,
port: parseInt(process.env.LOGSTASH_PORT),
})
);
```
---
## Consequences
### Positive Consequences
1.**Structured Logs:** ค้นหาและวิเคราะห์ได้ง่าย
2.**Performance Insights:** ดู Slow requests ได้
3.**Error Tracking:** ติดตาม Errors พร้อม Context
4.**Scalable:** พร้อมสำหรับ ELK Stack ในอนาคต
5.**Cost Effective:** ไม่มีค่าใช้จ่ายในช่วง MVP
### Negative Consequences
1.**Manual Log Search:** ใน Phase 1 ต้องค้นหา Logs ใน Files
2.**No Centralized Dashboard:** ต้องรอ Phase 2 (ELK)
3.**Log Rotation Management:** ต้อง Monitor disk space
### Mitigation Strategies
- **Docker Logging Driver:** ใช้ Docker log driver สำหรับ Log rotation
- **Log Aggregation:** ใช้ `docker logs` รวม Logs จากหลาย Containers
- **Monitoring:** Set up Disk space alerts
---
## Logging Best Practices
### DO:
- ✅ Log ทุก HTTP requests พร้อม Response time
- ✅ Log Business events สำคัญ (Approved, Rejected, Created)
- ✅ Log Errors พร้อม Stack trace และ Context
- ✅ ใช้ Structured logging (JSON format)
### DON'T:
- ❌ Log Sensitive data (Passwords, Tokens)
- ❌ Log ทุก Database query ใน Production
- ❌ Log Large payloads (> 1KB) ทั้งหมด
- ❌ ใช้ `console.log` แทน Logger
---
## Related ADRs
- [ADR-007: API Design & Error Handling](./ADR-007-api-design-error-handling.md)
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
---
## References
- [Winston Documentation](https://github.com/winstonjs/winston)
- [NestJS Logging](https://docs.nestjs.com/techniques/logger)
- [ELK Stack](https://www.elastic.co/elastic-stack)
---
**Last Updated:** 2025-12-01
**Next Review:** 2025-06-01

View File

@@ -0,0 +1,399 @@
# ADR-011: Next.js App Router & Routing Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Frontend Team, System Architect
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
---
## Context and Problem Statement
Next.js มี 2 รูปแบบ Router หลัก: Pages Router (เก่า) และ App Router (ใหม่ใน Next.js 13+) ต้องเลือกว่าจะใช้แบบไหนสำหรับ LCBP3-DMS
### ปัญหาที่ต้องแก้:
1. **Routing Architecture:** ใช้ Pages Router หรือ App Router
2. **Server vs Client Components:** จัดการ Data Fetching อย่างไร
3. **Layout System:** จัดการ Shared Layouts อย่างไร
4. **Performance:** ทำอย่างไรให้ Initial Load เร็ว
5. **SEO:** ต้องการ SEO หรือไม่ (Dashboard ไม่ต้องการ)
---
## Decision Drivers
- 🚀 **Performance:** Initial load time และ Navigation speed
- 🎯 **Developer Experience:** ง่ายต่อการพัฒนาและบำรุงรักษา
- 📦 **Code Organization:** โครงสร้างโค้ดชัดเจน
- 🔄 **Future-Proof:** พร้อมสำหรับ Next.js รุ่นถัดไป
- 🎨 **Layout Flexibility:** จัดการ Nested Layouts ได้ง่าย
---
## Considered Options
### Option 1: Pages Router (Traditional)
**โครงสร้าง:**
```
pages/
├── _app.tsx
├── _document.tsx
├── index.tsx
├── correspondences/
│ ├── index.tsx
│ └── [id].tsx
└── api/
└── ...
```
**Pros:**
- ✅ Mature และ Stable
- ✅ Documentation ครบถ้วน
- ✅ Community ใหญ่
- ✅ ทีมคุ้นเคยแล้ว
**Cons:**
- ❌ ไม่รองรับ Server Components
- ❌ Layout System ซับซ้อน (ต้องใช้ HOC)
- ❌ Data Fetching ไม่ทันสมัย
- ❌ Not recommended for new projects
### Option 2: App Router (New - Recommended)
**โครงสร้าง:**
```
app/
├── layout.tsx # Root layout
├── page.tsx # Home page
├── correspondences/
│ ├── layout.tsx # Nested layout
│ ├── page.tsx # List page
│ └── [id]/
│ └── page.tsx # Detail page
└── (auth)/
├── layout.tsx
└── login/
└── page.tsx
```
**Pros:**
- ✅ Server Components (Better performance)
- ✅ Built-in Layout System
- ✅ Streaming & Suspense support
- ✅ Better Data Fetching patterns
- ✅ Recommended by Next.js team
**Cons:**
- ❌ Newer (less community resources)
- ❌ Learning curve สำหรับทีม
- ❌ Some libraries ยังไม่รองรับ
### Option 3: Hybrid Approach
ใช้ App Router + Pages Router พร้อมกัน
**Pros:**
- ✅ Gradual migration
**Cons:**
- ❌ เพิ่มความซับซ้อน
- ❌ Confusing สำหรับทีม
---
## Decision Outcome
**Chosen Option:** **Option 2 - App Router**
### Rationale
1. **Future-Proof:** Next.js แนะนำให้ใช้ App Router สำหรับโปรเจกต์ใหม่
2. **Performance:** Server Components ช่วยลด JavaScript bundle size
3. **Better DX:** Layout System สะดวกกว่า
4. **Server Actions:** รองรับ Form submissions โดยไม่ต้องสร้าง API routes
5. **Learning Investment:** Team จะได้ Skill ที่ทันสมัย
---
## Implementation Details
### 1. Folder Structure
```
app/
├── (public)/ # Public routes (no auth)
│ ├── layout.tsx
│ └── login/
│ └── page.tsx
├── (dashboard)/ # Protected routes
│ ├── layout.tsx # Dashboard layout with sidebar
│ ├── page.tsx # Dashboard home
│ │
│ ├── correspondences/
│ │ ├── layout.tsx
│ │ ├── page.tsx # List
│ │ ├── new/
│ │ │ └── page.tsx # Create
│ │ └── [id]/
│ │ ├── page.tsx # Detail
│ │ └── edit/
│ │ └── page.tsx
│ │
│ ├── rfas/
│ ├── drawings/
│ └── settings/
├── api/ # API route handlers (minimal)
│ └── auth/
│ └── [...nextauth]/
│ └── route.ts
├── layout.tsx # Root layout
└── page.tsx # Root redirect
```
### 2. Root Layout
```typescript
// File: app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'LCBP3-DMS',
description: 'Document Management System for Laem Chabang Port Phase 3',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="th">
<body className={inter.className}>{children}</body>
</html>
);
}
```
### 3. Dashboard Layout (with Sidebar)
```typescript
// File: app/(dashboard)/layout.tsx
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// Server-side auth check
const session = await getServerSession();
if (!session) {
redirect('/login');
}
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}
```
### 4. Server Component (Data Fetching)
```typescript
// File: app/(dashboard)/correspondences/page.tsx
import { CorrespondenceList } from '@/components/correspondences/list';
import { getCorrespondences } from '@/lib/api/correspondences';
export default async function CorrespondencesPage({
searchParams,
}: {
searchParams: { page?: string; status?: string };
}) {
// Fetch data on server
const correspondences = await getCorrespondences({
page: parseInt(searchParams.page || '1'),
status: searchParams.status,
});
return (
<div>
<h1 className="text-2xl font-bold mb-6">Correspondences</h1>
<CorrespondenceList data={correspondences} />
</div>
);
}
```
### 5. Client Component (Interactive)
```typescript
// File: components/correspondences/list.tsx
'use client'; // Client Component
import { useState } from 'react';
import { Correspondence } from '@/types';
export function CorrespondenceList({ data }: { data: Correspondence[] }) {
const [filter, setFilter] = useState('');
const filtered = data.filter((item) =>
item.subject.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
type="text"
placeholder="Filter..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="border p-2 mb-4"
/>
<div>
{filtered.map((item) => (
<div key={item.id}>{item.subject}</div>
))}
</div>
</div>
);
}
```
### 6. Loading States
```typescript
// File: app/(dashboard)/correspondences/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-64 bg-gray-200 rounded animate-pulse" />
</div>
);
}
```
### 7. Error Handling
```typescript
// File: app/(dashboard)/correspondences/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-4">
<h2 className="text-xl font-bold text-red-600">Something went wrong!</h2>
<p className="text-gray-600">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
Try again
</button>
</div>
);
}
```
---
## Routing Patterns
### Route Groups (Organization)
```
(public)/ # Public pages
(dashboard)/ # Protected dashboard
(auth)/ # Auth-related pages
```
### Dynamic Routes
```
[id]/ # Dynamic segment (e.g., /correspondences/123)
[...slug]/ # Catch-all (e.g., /docs/a/b/c)
```
### Parallel Routes & Intercepting Routes
```
@modal/ # Parallel route for modals
(.)/ # Intercept same level
```
---
## Consequences
### Positive Consequences
1.**Better Performance:** Server Components ลด Client JavaScript
2.**SEO-Friendly:** Server-side rendering out of the box
3.**Simpler Layouts:** Nested layouts ทำได้ง่าย
4.**Streaming:** Progressive rendering with Suspense
5.**Future-Proof:** ทิศทางของ Next.js และ React
### Negative Consequences
1.**Learning Curve:** ทีมต้องเรียนรู้ Server Components
2.**Limited Libraries:** บาง Libraries ยังไม่รองรับ Server Components
3.**Debugging:** ยากกว่า Pages Router เล็กน้อย
### Mitigation Strategies
- **Training:** จัด Workshop เรื่อง App Router และ Server Components
- **Documentation:** เขียน Internal docs สำหรับ Patterns ที่ใช้
- **Code Review:** Review code ให้ใช้ Server/Client Components ถูกต้อง
- **Gradual Adoption:** เริ่มจาก Simple pages ก่อน
---
## Related ADRs
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ Next.js
- [ADR-012: UI Component Library](./ADR-012-ui-component-library.md) - Shadcn/UI
---
## References
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
- [React Server Components](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components)
---
**Last Updated:** 2025-12-01
**Next Review:** 2026-06-01

View File

@@ -0,0 +1,428 @@
# ADR-012: UI Component Library Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Frontend Team, UX Designer
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
---
## Context and Problem Statement
ต้องการ UI Component Library สำหรับสร้าง User Interface ที่สวยงาม สม่ำเสมอ และ Accessible
### ปัญหาที่ต้องแก้:
1. **Component Library:** ใช้ Library สำเร็จรูป หรือสร้างเอง
2. **Customization:** ปรับแต่งได้ง่ายเพียงใด
3. **Accessibility:** รองรับ ARIA และ Keyboard navigation
4. **Bundle Size:** ขนาดไฟล์ไม่ใหญ่เกินไป
5. **Developer Experience:** ใช้งานง่าย Documentation ครบ
---
## Decision Drivers
- 🎨 **Design Consistency:** UI สม่ำเสมอทั้งระบบ
-**Accessibility:** รองรับ WCAG 2.1 AA
- 🎯 **Customization:** ปรับแต่งได้ตามต้องการ
- 📦 **Bundle Size:** เล็กและ Tree-shakeable
-**Performance:** Render เร็ว
- 🛠️ **DX:** Developer Experience ดี
---
## Considered Options
### Option 1: Material-UI (MUI)
**Pros:**
- ✅ Component ครบชุด
- ✅ Documentation ดี
- ✅ Community ใหญ่
- ✅ Built-in theming
**Cons:**
- ❌ Bundle size ใหญ่
- ❌ Design opinionated (Material Design)
- ❌ Customization ยาก
- ❌ Performance overhead
### Option 2: Ant Design
**Pros:**
- ✅ Component ครบ (เน้น Enterprise)
- ✅ i18n support ดี
- ✅ Form components ครบ
**Cons:**
- ❌ Bundle size ใหญ่มาก
- ❌ Chinese-centric design
- ❌ Customization จำกัด
- ❌ TypeScript support ไม่ดีเท่าไร
### Option 3: Chakra UI
**Pros:**
- ✅ Accessibility ดี
- ✅ Customization ง่าย
- ✅ TypeScript first
- ✅ Dark mode built-in
**Cons:**
- ❌ Bundle size ค่อนข้างใหญ่
- ❌ CSS-in-JS overhead
- ❌ Performance issues with many components
### Option 4: Headless UI + Tailwind CSS
**Pros:**
- ✅ Full control over styling
- ✅ Lightweight
- ✅ Accessibility ดี
- ✅ No styling overhead
**Cons:**
- ❌ ต้องเขียน styles เอง
- ❌ Component library น้อย
- ❌ ใช้เวลาพัฒนานาน
### Option 5: Shadcn/UI + Tailwind CSS
**วิธีการ:** Copy components ที่ต้องการไปยัง Project
**Pros:**
-**Full ownership:** Components เป็นของเรา ไม่ใช่ dependency
-**Highly customizable:** แก้ไขได้เต็มที่
-**Accessibility:** ใช้ Radix UI Primitives
-**Bundle size:** เฉพาะที่ใช้เท่านั้น
-**Tailwind CSS:** Utility-first ง่ายต่อการ maintain
-**TypeScript:** Type-safe
-**Beautiful defaults:** Design ดูทันสมัย
**Cons:**
- ❌ ต้อง Copy components เอง
- ❌ Update ต้องทำด้วยตัวเอง
- ❌ ไม่มี `npm install` แบบ Library
---
## Decision Outcome
**Chosen Option:** **Option 5 - Shadcn/UI + Tailwind CSS**
### Rationale
1. **Ownership:** เป็นเจ้าของ Code 100% ปรับแต่งได้อย่างเต็มที่
2. **Bundle Size:** เล็กที่สุด (เฉพาะที่ใช้)
3. **Accessibility:** ใช้ Radix UI primitives ที่ทดสอบแล้ว
4. **Customization:** แก้ไขได้ตามต้องการ ไม่ติด Framework
5. **Tailwind CSS:** ทีมคุ้นเคยและใช้อยู่แล้ว
6. **Modern Design:** ดูสวยงามและทันสมัย
---
## Implementation Details
### 1. Setup Shadcn/UI
```bash
# Initialize shadcn/ui
npx shadcn-ui@latest init
# Select options:
# - TypeScript: Yes
# - Style: Default
# - Base color: Slate
# - CSS variables: Yes
```
```typescript
// File: components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
```
### 2. Add Components
```bash
# Add specific components
npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
npx shadcn-ui@latest add card
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add table
# Components will be copied to components/ui/
```
### 3. Component Usage
```typescript
// File: app/correspondences/page.tsx
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
export default function CorrespondencesPage() {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Input placeholder="Search..." className="max-w-sm" />
<Button>Create New</Button>
</div>
<Card className="p-6">
<h2 className="text-xl font-bold">Correspondences</h2>
{/* Content */}
</Card>
</div>
);
}
```
### 4. Customize Components
```typescript
// File: components/ui/button.tsx
// สามารถแก้ไขได้เต็มที่เพราะเป็น Code ของเรา
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
// Add custom variant
success: 'bg-green-600 text-white hover:bg-green-700',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
```
### 5. Theming with CSS Variables
```css
/* File: app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
/* ... more colors */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... dark mode colors */
}
}
```
### 6. Component Composition
```typescript
// File: components/correspondence/card.tsx
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
export function CorrespondenceCard({ correspondence }) {
return (
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle>{correspondence.subject}</CardTitle>
<Badge
variant={
correspondence.status === 'APPROVED' ? 'success' : 'default'
}
>
{correspondence.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{correspondence.description}
</p>
<div className="mt-4 flex gap-2">
<Button variant="outline" size="sm">
View
</Button>
<Button size="sm">Edit</Button>
</div>
</CardContent>
</Card>
);
}
```
---
## Component Inventory
### Core Components (มีอยู่ใน Shadcn/UI)
**Forms:**
- Button
- Input
- Textarea
- Select
- Checkbox
- Radio Group
- Switch
- Slider
- Label
**Data Display:**
- Table
- Card
- Badge
- Avatar
- Separator
**Feedback:**
- Alert
- Dialog
- Toast
- Progress
- Skeleton
**Navigation:**
- Tabs
- Dropdown Menu
- Command
- Popover
- Sheet (Drawer)
**Layout:**
- Accordion
- Collapsible
- Aspect Ratio
- Scroll Area
---
## Consequences
### Positive Consequences
1.**Full Control:** แก้ไข Components ได้เต็มที่
2.**Smaller Bundle:** เฉพาะที่ใช้เท่านั้น
3.**No Lock-in:** ไม่ติด Dependency
4.**Accessibility:** ใช้ Radix UI (tested)
5.**Beautiful Design:** ดูทันสมัยและสวยงาม
6.**TypeScript:** Type-safe
### Negative Consequences
1.**Manual Updates:** ต้อง Update components ด้วยตัวเอง
2.**Initial Setup:** ต้อง Copy components ที่ต้องการ
3.**No Official Support:** ไม่มี Package maintainer
### Mitigation Strategies
- **Documentation:** เขียนเอกสารว่า Components ไหนมา version ไหน
- **Changelog:** Track changes ที่ทำกับ Components
- **Testing:** เขียน Tests สำหรับ Custom components
- **Review Updates:** Check Shadcn/UI releases เป็นระยะ
---
## Related ADRs
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ Tailwind CSS
- [ADR-011: Next.js App Router](./ADR-011-nextjs-app-router.md)
---
## References
- [Shadcn/UI Documentation](https://ui.shadcn.com/)
- [Radix UI Primitives](https://www.radix-ui.com/)
- [Tailwind CSS](https://tailwindcss.com/)
---
**Last Updated:** 2025-12-01
**Next Review:** 2026-06-01

View File

@@ -0,0 +1,497 @@
# ADR-013: Form Handling & Validation Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Frontend Team
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md)
---
## Context and Problem Statement
ระบบ LCBP3-DMS มี Forms จำนวนมาก (Create/Edit Correspondence, RFA, Drawings) ต้องการวิธีจัดการ Forms ที่มี Performance ดี Validation ชัดเจน และ Developer Experience สูง
### ปัญหาที่ต้องแก้:
1. **Form State Management:** จัดการ Form state อย่างไร
2. **Validation:** Validate client-side และ server-side อย่างไร
3. **Error Handling:** แสดง Error messages อย่างไร
4. **Performance:** Forms ขนาดใหญ่ไม่ช้า
5. **Type Safety:** Type-safe forms with TypeScript
---
## Decision Drivers
-**Type Safety:** TypeScript support เต็มรูปแบบ
-**Performance:** Re-render minimal
- 🎯 **DX:** Developer Experience ดี
- 📝 **Validation:** Schema-based validation
- 🔄 **Reusability:** Reuse validation schema
- 🎨 **Flexibility:** ปรับแต่งได้ง่าย
---
## Considered Options
### Option 1: Formik
**Pros:**
- ✅ Popular และ Mature
- ✅ Documentation ดี
- ✅ Yup validation
**Cons:**
- ❌ Performance issues (re-renders)
- ❌ Bundle size ใหญ่
- ❌ TypeScript support ไม่ดีมาก
- ❌ Not actively maintained
### Option 2: Plain React State
```typescript
const [formData, setFormData] = useState({});
```
**Pros:**
- ✅ Simple
- ✅ No dependencies
**Cons:**
- ❌ Boilerplate code มาก
- ❌ ต้องจัดการ Validation เอง
- ❌ Error handling ซับซ้อน
- ❌ Performance issues
### Option 3: React Hook Form + Zod
**Pros:**
-**Performance:** Uncontrolled components (minimal re-renders)
-**TypeScript First:** Full type safety
-**Small Bundle:** ~8.5kb
-**Schema Validation:** Zod integration
-**DX:** Clean API
-**Actively Maintained**
**Cons:**
- ❌ Learning curve (uncontrolled approach)
- ❌ Complex forms ต้องใช้ Controller
---
## Decision Outcome
**Chosen Option:** **Option 3 - React Hook Form + Zod**
### Rationale
1. **Performance:** Uncontrolled components = minimal re-renders
2. **Type Safety:** Zod schemas → TypeScript types → Runtime validation
3. **Bundle Size:** เล็กมาก (8.5kb)
4. **Developer Experience:** API สะอาด ใช้งานง่าย
5. **Validation Reuse:** Validation schema ใช้ร่วมกับ Backend ได้
---
## Implementation Details
### 1. Install Dependencies
```bash
npm install react-hook-form zod @hookform/resolvers
```
### 2. Define Zod Schema
```typescript
// File: lib/validations/correspondence.ts
import { z } from 'zod';
export const correspondenceSchema = z.object({
subject: z
.string()
.min(5, 'Subject must be at least 5 characters')
.max(255, 'Subject must not exceed 255 characters'),
description: z
.string()
.min(10, 'Description must be at least 10 characters')
.optional(),
document_type_id: z.number({
required_error: 'Document type is required',
}),
from_organization_id: z.number({
required_error: 'From organization is required',
}),
to_organization_id: z.number({
required_error: 'To organization is required',
}),
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
attachments: z.array(z.instanceof(File)).optional(),
});
// Export TypeScript type
export type CorrespondenceFormData = z.infer<typeof correspondenceSchema>;
```
### 3. Create Form Component
```typescript
// File: components/correspondences/create-form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
correspondenceSchema,
type CorrespondenceFormData,
} from '@/lib/validations/correspondence';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export function CreateCorrespondenceForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
} = useForm<CorrespondenceFormData>({
resolver: zodResolver(correspondenceSchema),
defaultValues: {
importance: 'NORMAL',
},
});
const onSubmit = async (data: CorrespondenceFormData) => {
try {
const response = await fetch('/api/correspondences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create');
// Success - redirect
window.location.href = '/correspondences';
} catch (error) {
console.error(error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Subject */}
<div>
<Label htmlFor="subject">Subject *</Label>
<Input
id="subject"
{...register('subject')}
placeholder="Enter subject"
/>
{errors.subject && (
<p className="text-sm text-red-600 mt-1">{errors.subject.message}</p>
)}
</div>
{/* Description */}
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Enter description"
rows={4}
/>
{errors.description && (
<p className="text-sm text-red-600 mt-1">
{errors.description.message}
</p>
)}
</div>
{/* Document Type (Select) */}
<div>
<Label>Document Type *</Label>
<Select
onValueChange={(value) =>
setValue('document_type_id', parseInt(value))
}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Internal Letter</SelectItem>
<SelectItem value="2">External Letter</SelectItem>
</SelectContent>
</Select>
{errors.document_type_id && (
<p className="text-sm text-red-600 mt-1">
{errors.document_type_id.message}
</p>
)}
</div>
{/* Importance (Radio) */}
<div>
<Label>Importance</Label>
<div className="flex gap-4 mt-2">
<label className="flex items-center">
<input type="radio" value="NORMAL" {...register('importance')} />
<span className="ml-2">Normal</span>
</label>
<label className="flex items-center">
<input type="radio" value="HIGH" {...register('importance')} />
<span className="ml-2">High</span>
</label>
<label className="flex items-center">
<input type="radio" value="URGENT" {...register('importance')} />
<span className="ml-2">Urgent</span>
</label>
</div>
</div>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
);
}
```
### 4. Reusable Form Field Component
```typescript
// File: components/ui/form-field.tsx
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { UseFormRegister, FieldError } from 'react-hook-form';
interface FormFieldProps {
label: string;
name: string;
type?: string;
register: UseFormRegister<any>;
error?: FieldError;
required?: boolean;
placeholder?: string;
}
export function FormField({
label,
name,
type = 'text',
register,
error,
required = false,
placeholder,
}: FormFieldProps) {
return (
<div>
<Label htmlFor={name}>
{label} {required && <span className="text-red-600">*</span>}
</Label>
<Input
id={name}
type={type}
{...register(name)}
placeholder={placeholder}
className={error ? 'border-red-600' : ''}
/>
{error && <p className="text-sm text-red-600 mt-1">{error.message}</p>}
</div>
);
}
```
### 5. File Upload Handling
```typescript
// File: components/correspondences/file-upload.tsx
'use client';
import { useState } from 'react';
import { UseFormSetValue } from 'react-hook-form';
import { Button } from '@/components/ui/button';
interface FileUploadProps {
setValue: UseFormSetValue<any>;
fieldName: string;
}
export function FileUpload({ setValue, fieldName }: FileUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
setFiles(selectedFiles);
setValue(fieldName, selectedFiles);
};
return (
<div>
<input
type="file"
multiple
onChange={handleFileChange}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload">
<Button type="button" variant="outline" asChild>
<span>Choose Files</span>
</Button>
</label>
{files.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
{files.map((file, i) => (
<div key={i}>{file.name}</div>
))}
</div>
)}
</div>
);
}
```
### 6. Server-Side Validation
```typescript
// File: app/api/correspondences/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { correspondenceSchema } from '@/lib/validations/correspondence';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate with same Zod schema
const validated = correspondenceSchema.parse(body);
// Create correspondence
// ...
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
```
---
## Form Patterns
### Dynamic Fields
```typescript
import { useFieldArray } from 'react-hook-form';
const { fields, append, remove } = useFieldArray({
control,
name: 'items', // RFA items
});
// Add item
append({ description: '', quantity: 0 });
// Remove item
remove(index);
```
### Controlled Components
```typescript
import { Controller } from 'react-hook-form';
<Controller
name="discipline_id"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
{/* Options */}
</Select>
)}
/>;
```
---
## Consequences
### Positive Consequences
1.**Performance:** Minimal re-renders (uncontrolled)
2.**Type Safety:** Full TypeScript support
3.**Validation Reuse:** Same schema for client & server
4.**Small Bundle:** ~8.5kb only
5.**Clean Code:** Less boilerplate
6.**Error Handling:** Built-in error states
### Negative Consequences
1.**Learning Curve:** Uncontrolled approach ต่างจาก Formik
2.**Complex Forms:** ต้องใช้ Controller บางครั้ง
### Mitigation Strategies
- **Documentation:** เขียน Form patterns และ Examples
- **Reusable Components:** สร้าง FormField wrapper
- **Code Review:** Review forms ให้ใช้ best practices
---
## Related ADRs
- [ADR-007: API Design & Error Handling](./ADR-007-api-design-error-handling.md)
- [ADR-012: UI Component Library](./ADR-012-ui-component-library.md)
---
## References
- [React Hook Form Documentation](https://react-hook-form.com/)
- [Zod Documentation](https://zod.dev/)
---
**Last Updated:** 2025-12-01
**Next Review:** 2026-06-01

View File

@@ -0,0 +1,400 @@
# ADR-014: State Management Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Frontend Team
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md), [ADR-011: App Router](./ADR-011-nextjs-app-router.md)
---
## Context and Problem Statement
ระบบ LCBP3-DMS ต้องการจัดการ Global State เช่น User session, Notifications, UI preferences ต้องเลือก State Management solution ที่เหมาะสม
### ปัญหาที่ต้องแก้:
1. **Global State:** จัดการ State ที่ใช้ร่วมกันทั้งแอปอย่างไร
2. **Server State:** จัดการข้อมูลจาก API อย่างไร
3. **Performance:** หลีกเลี่ยง Unnecessary re-renders
4. **Type Safety:** Type-safe state management
5. **Bundle Size:** ไม่ทำให้ Bundle ใหญ่เกินไป
---
## Decision Drivers
-**Performance:** Minimal re-renders
- 📦 **Bundle Size:** เล็กที่สุด
- 🎯 **Simplicity:** เรียนรู้และใช้งานง่าย
-**Type Safety:** TypeScript support
- 🔄 **Server State:** จัดการ API data ได้ดี
---
## Considered Options
### Option 1: Redux Toolkit
**Pros:**
- ✅ Industry standard
- ✅ DevTools ดี
- ✅ Middleware support
**Cons:**
- ❌ Boilerplate มาก
- ❌ Bundle size ใหญ่ (~40kb)
- ❌ Complexity สูง
- ❌ Overkill สำหรับ App ส่วนใหญ่
### Option 2: React Context API
**Pros:**
- ✅ Built-in (no dependencies)
- ✅ Simple
**Cons:**
- ❌ Performance issues (re-render ทั้ง tree)
- ❌ ไม่เหมาะสำหรับ Complex state
- ❌ ต้องจัดการ Optimization เอง
### Option 3: Zustand
**Props:**
-**Lightweight:** ~1.2kb only
-**Simple API:** เรียนรู้ง่าย
-**Performance:** Selective re-renders
-**TypeScript:** Full support
-**No boilerplate**
-**DevTools support**
**Cons:**
- ❌ Smaller community กว่า Redux
### Option 4: React Query (TanStack Query) for Server State
**Pros:**
-**Specialized:** จัดการ Server state ได้ดีที่สุด
-**Caching:** Auto cache management
-**Refetching:** Auto refetch on focus
-**TypeScript:** Excellent support
**Cons:**
- ❌ เฉพาะ Server state (ต้องใช้คู่กับ Client state solution)
---
## Decision Outcome
**Chosen Option:** **Zustand (Client State) + Native Fetch with Server Components (Server State)**
### Rationale
**For Client State (UI state, Preferences):**
- Use **Zustand** - lightweight และเรียนรู้ง่าย
**For Server State (API data):**
- Use **Server Components** + **SWR** (เฉพาะที่จำเป็น)
- Server Components ดึงข้อมูลฝั่ง Server ไม่ต้องจัดการ state
---
## Implementation Details
### 1. Install Zustand
```bash
npm install zustand
```
### 2. Create Global Store (User Session)
```typescript
// File: lib/stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
// Actions
setAuth: (user: User, token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) =>
set({
user,
token,
isAuthenticated: true,
}),
logout: () =>
set({
user: null,
token: null,
isAuthenticated: false,
}),
}),
{
name: 'auth-storage', // LocalStorage key
}
)
);
```
### 3. Use Store in Components
```typescript
// File: components/header.tsx
'use client';
import { useAuthStore } from '@/lib/stores/auth-store';
import { Button } from '@/components/ui/button';
export function Header() {
const { user, logout } = useAuthStore();
return (
<header className="flex justify-between items-center p-4">
<div>Welcome, {user?.first_name}</div>
<Button onClick={logout}>Logout</Button>
</header>
);
}
```
### 4. Notifications Store
```typescript
// File: lib/stores/notification-store.ts
import { create } from 'zustand';
interface Notification {
id: string;
type: 'success' | 'error' | 'info';
message: string;
}
interface NotificationState {
notifications: Notification[];
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
clearAll: () => void;
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
addNotification: (notification) =>
set((state) => ({
notifications: [
...state.notifications,
{ ...notification, id: Math.random().toString() },
],
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
clearAll: () => set({ notifications: [] }),
}));
```
### 5. Server State with Server Components
```typescript
// File: app/(dashboard)/correspondences/page.tsx
// Server Component - No state management needed!
import { getCorrespondences } from '@/lib/api/correspondences';
export default async function CorrespondencesPage() {
// Fetch directly on server
const correspondences = await getCorrespondences();
return (
<div>
<h1>Correspondences</h1>
{correspondences.map((item) => (
<div key={item.id}>{item.subject}</div>
))}
</div>
);
}
```
### 6. Client-Side Fetching (with SWR for real-time data)
```bash
npm install swr
```
```typescript
// File: components/correspondences/realtime-list.tsx
'use client';
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function RealtimeCorrespondenceList() {
const { data, error, isLoading, mutate } = useSWR(
'/api/correspondences',
fetcher,
{
refreshInterval: 30000, // Auto refresh every 30s
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading data</div>;
return (
<div>
{data.map((item) => (
<div key={item.id}>{item.subject}</div>
))}
<button onClick={() => mutate()}>Refresh</button>
</div>
);
}
```
### 7. UI Preferences Store
```typescript
// File: lib/stores/ui-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
sidebarCollapsed: boolean;
theme: 'light' | 'dark';
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
theme: 'light',
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setTheme: (theme) => set({ theme }),
}),
{
name: 'ui-preferences',
}
)
);
```
---
## State Management Patterns
### When to Use Zustand (Client State)
✅ Use Zustand for:
- User authentication state
- UI preferences (theme, sidebar state)
- Notifications/Toasts
- Shopping cart (if applicable)
- Form wizard state
- Modal state (global)
### When to Use Server Components (Server State)
✅ Use Server Components for:
- Initial data loading
- Static/semi-static data
- SEO-important content
- Data that doesn't need real-time updates
### When to Use SWR (Client-Side Server State)
✅ Use SWR for:
- Real-time data (notifications count)
- Polling/Auto-refresh data
- User-specific data that changes often
- Optimistic UI updates
---
## Consequences
### Positive Consequences
1.**Lightweight:** Zustand ~1.2kb
2.**Simple:** Easy to learn and use
3.**Performance:** Selective re-renders
4.**No Boilerplate:** Clean API
5.**Type Safe:** Full TypeScript support
6.**Persistent:** Easy LocalStorage persist
### Negative Consequences
1.**Smaller Ecosystem:** กว่า Redux
2.**Less Tooling:** DevTools ไม่ครบเท่า Redux
### Mitigation Strategies
- **Documentation:** Document common patterns
- **Code Examples:** Provide store templates
- **Testing:** Unit test stores thoroughly
---
## Related ADRs
- [ADR-011: Next.js App Router](./ADR-011-nextjs-app-router.md) - Server Components
- [ADR-007: API Design](./ADR-007-api-design-error-handling.md)
---
## References
- [Zustand Documentation](https://github.com/pmndrs/zustand)
- [SWR Documentation](https://swr.vercel.app/)
---
**Last Updated:** 2025-12-01
**Next Review:** 2026-06-01

View File

@@ -0,0 +1,457 @@
# ADR-015: Deployment & Infrastructure Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** DevOps Team, System Architect
**Related Documents:** [ADR-005: Technology Stack](./ADR-005-technology-stack.md), [Operations Guide](../04-operations/)
---
## Context and Problem Statement
LCBP3-DMS ต้อง Deploy บน QNAP Container Station โดยใช้ Docker แต่ต้องเลือกกลย modularุทธ์การ Deploy, การจัดการ Environment, และการ Scale ที่เหมาะสม
### ปัญหาที่ต้องแก้:
1. **Container Orchestration:** ใช้ Docker Compose หรือ Kubernetes
2. **Environment Management:** จัดการ Environment Variables อย่างไร
3. **Deployment Strategy:** Blue-Green, Rolling Update, หรือ Recreate
4. **Scaling:** แผน Scale horizontal/vertical
5. **Persistence:** จัดการ Data persistence อย่างไร
---
## Decision Drivers
- 🎯 **Simplicity:** ง่ายต่อการ Deploy และ Maintain
- 🔒 **Security:** Secrets management ปลอดภัย
-**Zero Downtime:** Deploy ได้โดยไม่มี Downtime
- 📦 **Resource Efficiency:** ใช้ทรัพยากร QNAP อย่างคุ้มค่า
- 🔄 **Rollback Capability:** Rollback ได้เมื่อมีปัญหา
---
## Considered Options
### Option 1: Docker Compose (Single Server)
**Deployment:**
```yaml
version: '3.8'
services:
backend:
image: lcbp3-backend:latest
environment:
- NODE_ENV=production
env_file:
- .env.production
volumes:
- ./uploads:/app/uploads
- ./logs:/app/logs
depends_on:
- mariadb
- redis
networks:
- lcbp3-network
frontend:
image: lcbp3-frontend:latest
depends_on:
- backend
networks:
- lcbp3-network
mariadb:
image: mariadb:10.11
volumes:
- mariadb-data:/var/lib/mysql
networks:
- lcbp3-network
redis:
image: redis:7.2-alpine
volumes:
- redis-data:/data
networks:
- lcbp3-network
elasticsearch:
image: elasticsearch:8.11.0
volumes:
- elastic-data:/usr/share/elasticsearch/data
networks:
- lcbp3-network
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
ports:
- '80:80'
- '443:443'
depends_on:
- backend
- frontend
networks:
- lcbp3-network
volumes:
mariadb-data:
redis-data:
elastic-data:
networks:
lcbp3-network:
```
**Pros:**
- ✅ Simple และเข้าใจง่าย
- ✅ พอดีกับ QNAP Container Station
- ✅ Resource requirement ต่ำ
- ✅ Debugging ง่าย
**Cons:**
- ❌ Single point of failure
- ❌ ไม่มี Auto-scaling
- ❌ Service discovery manual
### Option 2: Kubernetes (k3s)
**Pros:**
- ✅ Auto-scaling
- ✅ Self-healing
- ✅ Service discovery
**Cons:**
- ❌ ซับซ้อนเกินความจำเป็น
- ❌ Resource overhead สูง
- ❌ Learning curve สูง
- ❌ Overkill สำหรับ Single server
---
## Decision Outcome
**Chosen Option:** **Docker Compose with Blue-Green Deployment Strategy**
### Rationale
1. **Appropriate Complexity:** เหมาะกับ Scale และทีมของโปรเจกต์
2. **QNAP Compatibility:** รองรับโดย QNAP Container Station
3. **Resource Efficiency:** ใช้ทรัพยากรน้อยกว่า K8s
4. **Team Familiarity:** ทีม DevOps คุ้นเคยกับ Docker Compose
5. **Easy Rollback:** Rollback ได้ง่ายด้วย Tagged images
---
## Implementation Details
### 1. Directory Structure
```
/volume1/lcbp3/
├── blue/
│ ├── docker-compose.yml
│ ├── .env.production
│ └── nginx.conf
├── green/
│ ├── docker-compose.yml
│ ├── .env.production
│ └── nginx.conf
├── nginx-proxy/
│ ├── docker-compose.yml
│ └── nginx.conf (routes to blue or green)
├── shared/
│ ├── uploads/
│ ├── logs/
│ └── backups/
└── volumes/
├── mariadb-data/
├── redis-data/
└── elastic-data/
```
### 2. Blue-Green Deployment Process
```bash
#!/bin/bash
# File: scripts/deploy.sh
CURRENT=$(cat /volume1/lcbp3/current)
TARGET=$([[ "$CURRENT" == "blue" ]] && echo "green" || echo "blue")
echo "Current environment: $CURRENT"
echo "Deploying to: $TARGET"
cd /volume1/lcbp3/$TARGET
# 1. Pull latest images
docker-compose pull
# 2. Start new environment
docker-compose up -d
# 3. Run database migrations
docker exec lcbp3-${TARGET}-backend npm run migration:run
# 4. Health check
for i in {1..30}; do
if curl -f http://localhost:${TARGET}_PORT/health; then
echo "Health check passed"
break
fi
sleep 2
done
# 5. Switch nginx to new environment
sed -i "s/$CURRENT/$TARGET/g" /volume1/lcbp3/nginx-proxy/nginx.conf
docker exec lcbp3-nginx nginx -s reload
# 6. Update current pointer
echo "$TARGET" > /volume1/lcbp3/current
# 7. Stop old environment (keep data)
cd /volume1/lcbp3/$CURRENT
docker-compose down
echo "Deployment complete: $TARGET is now active"
```
### 3. Environment Variables Management
```bash
# File: .env.production (NOT in Git)
NODE_ENV=production
# Database
DB_HOST=mariadb
DB_PORT=3306
DB_USERNAME=lcbp3_user
DB_PASSWORD=<secret>
DB_DATABASE=lcbp3_dms
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=<secret>
# JWT
JWT_SECRET=<secret>
JWT_EXPIRES_IN=7d
# File Storage
UPLOAD_PATH=/app/uploads
ALLOWED_FILE_TYPES=.pdf,.doc,.docx,.xls,.xlsx,.dwg
# Email
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=<secret>
SMTP_PASSWORD=<secret>
```
**Secrets Management:**
- Production `.env` files stored on QNAP only (NOT in Git)
- Use `docker-compose.override.yml` for local development
- Validate required env vars at application startup
### 4. Volume Management
```yaml
volumes:
# Persistent data (survives container recreation)
mariadb-data:
driver: local
driver_opts:
type: none
device: /volume1/lcbp3/volumes/mariadb-data
o: bind
# Shared uploads across blue/green
uploads:
driver: local
driver_opts:
type: none
device: /volume1/lcbp3/shared/uploads
o: bind
# Logs
logs:
driver: local
driver_opts:
type: none
device: /volume1/lcbp3/shared/logs
o: bind
```
### 5. NGINX Reverse Proxy
```nginx
# File: nginx-proxy/nginx.conf
upstream backend {
server lcbp3-blue-backend:3000; # Switch to green during deployment
}
upstream frontend {
server lcbp3-blue-frontend:3000;
}
server {
listen 80;
server_name lcbp3-dms.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name lcbp3-dms.example.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# Frontend
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Backend API
location /api {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Timeouts for file uploads
client_max_body_size 50M;
proxy_read_timeout 300s;
}
# Health check
location /health {
proxy_pass http://backend/health;
access_log off;
}
}
```
---
## Scaling Strategy
### Vertical Scaling (Phase 1)
**Current Recommendation:**
- Backend: 2 CPU cores, 4GB RAM
- Frontend: 1 CPU core, 2GB RAM
- MariaDB: 2 CPU cores, 8GB RAM
- Redis: 1 CPU core, 2GB RAM
- Elasticsearch: 2 CPU cores, 4GB RAM
**Upgrade Path:**
- Increase CPU/RAM ตาม Load
- Monitor with Prometheus/Grafana
### Horizontal Scaling (Phase 2 - Future)
**If needed:**
- Load Balancer หน้า Backend (multiple replicas)
- Database Read Replicas
- Redis Cluster
- Elasticsearch Cluster
**Prerequisite:**
- Stateless application (sessions in Redis)
- Shared file storage (NFS/S3)
---
## Deployment Checklist
```markdown
### Pre-Deployment
- [ ] Backup database
- [ ] Tag Docker images
- [ ] Update .env file
- [ ] Review migration scripts
- [ ] Notify stakeholders
### Deployment
- [ ] Pull latest images
- [ ] Start target environment (blue/green)
- [ ] Run migrations
- [ ] Health check passes
- [ ] Switch NGINX proxy
- [ ] Verify application working
### Post-Deployment
- [ ] Monitor logs for errors
- [ ] Check performance metrics
- [ ] Verify all features working
- [ ] Stop old environment
- [ ] Update deployment log
```
---
## Consequences
### Positive Consequences
1.**Simple Deployment:** Docker Compose เข้าใจง่าย
2.**Zero Downtime:** Blue-Green Deployment ไม่มี Downtime
3.**Easy Rollback:** Rollback = Switch NGINX back
4.**Cost Effective:** ไม่ต้อง Kubernetes overhead
5.**QNAP Compatible:** ใช้ได้กับ Container Station
### Negative Consequences
1.**Manual Scaling:** ต้อง Scale manual
2.**Single Server:** ไม่มี High Availability
3.**Limited Auto-healing:** ต้อง Monitor และ Restart manual
### Mitigation Strategies
- **Monitoring:** Setup Prometheus + Alertmanager
- **Automated Backups:** Cron jobs สำหรับ Database backups
- **Documentation:** เขียน Runbook สำหรับ Common issues
- **Health Checks:** Implement comprehensive health endpoints
---
## Related ADRs
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
- [ADR-009: Database Migration Strategy](./ADR-009-database-migration-strategy.md)
---
## References
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Blue-Green Deployment](https://martinfowler.com/bliki/BlueGreenDeployment.html)
- [QNAP Container Station](https://www.qnap.com/en/software/container-station)
---
**Last Updated:** 2025-12-01
**Next Review:** 2026-06-01

View File

@@ -0,0 +1,451 @@
# ADR-016: Security & Authentication Strategy
**Status:** ✅ Accepted
**Date:** 2025-12-01
**Decision Makers:** Security Team, System Architect
**Related Documents:** [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md), [ADR-007: API Design](./ADR-007-api-design-error-handling.md)
---
## Context and Problem Statement
LCBP3-DMS จัดการเอกสารสำคัญของโปรเจกต์ ต้องการ Security strategy ที่ครอบคลุม Authentication, Authorization, Data protection, และ Security best practices
### ปัญหาที่ต้องแก้:
1. **Authentication:** ใช้วิธีไหนในการยืนยันตัวตน
2. **Session Management:** จัดการ Session อย่างไร
3. **Password Security:** เก็บ Password อย่างไรให้ปลอดภัย
4. **Data Encryption:** Encrypt ข้อมูลอย่างไร
5. **Security Headers:** HTTP Headers ที่ต้องมี
6. **Input Validation:** ป้องกัน Injection attacks
7. **Rate Limiting:** ป้องกัน Brute force attacks
---
## Decision Drivers
- 🔒 **Security First:** ความปลอดภัยเป็นสำคัญที่สุด
-**Industry Standards:** ใช้ Standard practices (OWASP)
- 🎯 **User Experience:** ไม่ซับซ้อนเกินไป
- 📝 **Audit Trail:** บันทึก Security events ทั้งหมด
- 🔄 **Token Refresh:** Session management ที่สะดวก
---
## Decision Outcome
### 1. Authentication Strategy
**Chosen:** **JWT (JSON Web Tokens) with HTTP-only Cookies**
```typescript
// File: src/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly usersService: UsersService
) {}
async login(credentials: LoginDto): Promise<{ tokens }> {
const user = await this.validateUser(credentials);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const payload = {
sub: user.user_id,
username: user.username,
roles: user.roles,
};
// Generate tokens
const accessToken = this.jwtService.sign(payload, {
expiresIn: '15m', // Short-lived
});
const refreshToken = this.jwtService.sign(payload, {
secret: process.env.JWT_REFRESH_SECRET,
expiresIn: '7d', // Long-lived
});
// Store refresh token (hashed) in database
await this.storeRefreshToken(user.user_id, refreshToken);
return { accessToken, refreshToken };
}
private async validateUser(credentials: LoginDto) {
const user = await this.usersService.findByUsername(credentials.username);
if (!user) return null;
// Use bcrypt for password comparison
const isValid = await bcrypt.compare(
credentials.password,
user.password_hash
);
return isValid ? user : null;
}
}
```
### 2. Password Security
**Strategy:** **bcrypt with salt rounds = 12**
```typescript
import * as bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
// Hash password
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
// Verify password
async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
```
**Password Policy:**
- Minimum 8 characters
- Mix of uppercase, lowercase, numbers
- No common passwords (check against dictionary)
- Password history (last 5 passwords)
- Force change every 90 days (optional)
### 3. JWT Guard (Authorization)
```typescript
// File: src/common/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err, user, info) {
if (err || !user) {
throw new UnauthorizedException(info?.message || 'Unauthorized');
}
return user;
}
}
```
### 4. Data Encryption
**At Rest:**
- Database: Use MariaDB encryption at column level (for sensitive fields)
- Files: Encrypt before storing (AES-256)
```typescript
import * as crypto from 'crypto';
const algorithm = 'aes-256-gcm';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
function encrypt(text: string): { encrypted: string; iv: string; tag: string } {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
return {
encrypted,
iv: iv.toString('hex'),
tag: tag.toString('hex'),
};
}
function decrypt(encrypted: string, iv: string, tag: string): string {
const decipher = crypto.createDecipheriv(
algorithm,
key,
Buffer.from(iv, 'hex')
);
decipher.setAuthTag(Buffer.from(tag, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
```
**In Transit:**
- HTTPS only (TLS 1.3)
- HSTS enabled
- Certificate from trusted CA
### 5. Security Headers
```typescript
// File: src/main.ts
import helmet from 'helmet';
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
frameguard: { action: 'deny' },
xssFilter: true,
noSniff: true,
})
);
// CORS
app.enableCors({
origin: process.env.FRONTEND_URL,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
```
### 6. Input Validation
**Strategy:** **Class-validator + Zod + Custom Sanitization**
```typescript
// DTO Validation
import { IsString, IsEmail, MinLength, Matches } from 'class-validator';
export class LoginDto {
@IsString()
@MinLength(3)
username: string;
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'Password must contain uppercase, lowercase, and number',
})
password: string;
}
// SQL Injection Prevention (TypeORM handles this)
// Use parameterized queries ALWAYS
// XSS Prevention
import * as sanitizeHtml from 'sanitize-html';
function sanitizeInput(input: string): string {
return sanitizeHtml(input, {
allowedTags: [], // No HTML tags
allowedAttributes: {},
});
}
```
### 7. Rate Limiting
```typescript
// File: src/common/guards/rate-limit.guard.ts
import { ThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
protected getTracker(req: Request): string {
// Track by IP + User ID (if authenticated)
return req.ip + (req.user?.user_id || '');
}
}
// Apply to login endpoint
@Controller('auth')
@UseGuards(CustomThrottlerGuard)
export class AuthController {
@Post('login')
@Throttle(5, 60) // 5 attempts per minute
async login(@Body() credentials: LoginDto) {
return this.authService.login(credentials);
}
}
```
### 8. Session Management
**Strategy:** **Stateless JWT + Refresh Token in Database**
```typescript
// Refresh token table
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn()
token_id: number;
@Column()
user_id: number;
@Column()
token_hash: string; // SHA-256 hash of token
@Column()
expires_at: Date;
@Column({ default: false })
is_revoked: boolean;
@CreateDateColumn()
created_at: Date;
}
// Token refresh endpoint
@Post('refresh')
async refresh(@Body('refreshToken') token: string) {
const payload = this.jwtService.verify(token, {
secret: process.env.JWT_REFRESH_SECRET,
});
// Check if token is revoked
const storedToken = await this.findRefreshToken(token);
if (!storedToken || storedToken.is_revoked) {
throw new UnauthorizedException('Invalid refresh token');
}
// Generate new access token
const newAccessToken = this.jwtService.sign({
sub: payload.sub,
username: payload.username,
roles: payload.roles,
});
return { accessToken: newAccessToken };
}
```
### 9. Audit Logging (Security Events)
```typescript
// Log all security-related events
await this.auditLogService.create({
user_id: user.user_id,
action: 'LOGIN_SUCCESS',
entity_type: 'auth',
ip_address: req.ip,
user_agent: req.headers['user-agent'],
});
// Track failed login attempts
await this.auditLogService.create({
action: 'LOGIN_FAILED',
entity_type: 'auth',
ip_address: req.ip,
details: { username: credentials.username },
});
```
---
## Security Checklist
### Application Security
- [x] JWT authentication with short-lived tokens
- [x] Password hashing with bcrypt (12 rounds)
- [x] HTTPS only (TLS 1.3)
- [x] Security headers (Helmet.js)
- [x] CORS properly configured
- [x] Input validation (class-validator)
- [x] SQL injection prevention (TypeORM)
- [x] XSS prevention (sanitize-html)
- [x] CSRF protection (SameSite cookies)
- [x] Rate limiting (Throttler)
### Data Security
- [x] Sensitive data encrypted at rest (AES-256)
- [x] Passwords hashed (bcrypt)
- [x] Secrets in environment variables (not in code)
- [x] Database credentials rotated regularly
- [x] Backup encryption enabled
### Access Control
- [x] 4-level RBAC implemented
- [x] Principle of least privilege
- [x] Role-based permissions
- [x] Session timeout (15 minutes)
- [x] Audit logging for all actions
### Infrastructure
- [x] Firewall configured
- [x] Intrusion detection (optional)
- [x] Regular security updates
- [x] Vulnerability scanning
- [x] Penetration testing (before go-live)
---
## Consequences
### Positive Consequences
1.**Secure by Design:** ใช้ Industry best practices
2.**OWASP Compliant:** ครอบคลุม OWASP Top 10
3.**Audit Trail:** บันทึก Security events ทั้งหมด
4.**Token-based:** Stateless และ Scalable
5.**Defense in Depth:** หลายชั้นการป้องกัน
### Negative Consequences
1.**Complexity:** Security measures เพิ่ม Complexity
2.**Performance:** Encryption/Hashing ใช้ CPU
3.**User Friction:** Password policy อาจรำคาญผู้ใช้
### Mitigation Strategies
- **Documentation:** เขียน Security guidelines ให้ทีม
- **Training:** อบรม Security awareness
- **Automation:** Automated security scans
- **Monitoring:** Real-time security monitoring
---
## Related ADRs
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md)
- [ADR-007: API Design & Error Handling](./ADR-007-api-design-error-handling.md)
- [ADR-015: Deployment & Infrastructure](./ADR-015-deployment-infrastructure.md)
---
## References
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [JWT Best Practices](https://curity.io/resources/learn/jwt-best-practices/)
- [NestJS Security](https://docs.nestjs.com/security/authentication)
---
**Last Updated:** 2025-12-01
**Next Review:** 2026-03-01 (Quarterly review)

View File

@@ -0,0 +1,356 @@
# Architecture Decision Records (ADRs)
**Last Updated:** 2025-11-30
**Project:** LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)
---
## 📋 What are ADRs?
Architecture Decision Records (ADRs) เป็นเอกสารที่บันทึก **ประวัติการตัดสินใจทางสถาปัตยกรรมที่สำคัญ** ของโปรเจกต์ โดยร ะบุ:
- **Context**: เหตุผลที่ต้องตัดสินใจ
- **Options Considered**: ทางเลือกที่พิจารณา
- **Decision**: สิ่งที่เลือก และเหตุผล
- **Consequences**: ผลที่ตามมา (ดีและไม่ดี)
**วัตถุประสงค์:**
1. ทำให้ทีมเข้าใจ "ทำไม" นอกเหนือจาก "ทำอย่างไร"
2. ป้องกันการสงสัยว่า "ทำไมถึงออกแบบแบบนี้" ในอนาคต
3. ช่วยในการ Onboard สมาชิกใหม่
4. บันทึกประวัติศาสตร์การพัฒนาโปรเจกต์
---
## 📚 ADR Index
### Core Architecture Decisions
| ADR | Title | Status | Date | Summary |
| --------------------------------------------------- | ------------------------------- | ----------- | ---------- | ---------------------------------------------------------------------------- |
| [ADR-001](./ADR-001-unified-workflow-engine.md) | Unified Workflow Engine | ✅ Accepted | 2025-11-30 | ใช้ DSL-based Workflow Engine สำหรับ Correspondences, RFAs, และ Circulations |
| [ADR-002](./ADR-002-document-numbering-strategy.md) | Document Numbering Strategy | ✅ Accepted | 2025-11-30 | Double-lock mechanism (Redis + DB Optimistic Lock) สำหรับเลขที่เอกสาร |
| [ADR-003](./ADR-003-file-storage-approach.md) | Two-Phase File Storage Approach | ✅ Accepted | 2025-11-30 | Upload → Temp → Commit to Permanent เพื่อป้องกัน Orphan Files |
### Security & Access Control
| ADR | Title | Status | Date | Summary |
| ------------------------------------------- | ----------------------------- | ----------- | ---------- | ------------------------------------------------------------- |
| [ADR-004](./ADR-004-rbac-implementation.md) | RBAC Implementation (4-Level) | ✅ Accepted | 2025-11-30 | Hierarchical RBAC: Global → Organization → Project → Contract |
### Technology & Infrastructure
| ADR | Title | Status | Date | Summary |
| --------------------------------------------------- | ------------------------------------ | ----------- | ---------- | -------------------------------------------------------------- |
| [ADR-005](./ADR-005-technology-stack.md) | Technology Stack Selection | ✅ Accepted | 2025-11-30 | Full Stack TypeScript: NestJS + Next.js + MariaDB + Redis |
| [ADR-006](./ADR-006-redis-caching-strategy.md) | Redis Usage & Caching Strategy | ✅ Accepted | 2025-11-30 | Redis สำหรับ Distributed Lock, Cache, Queue, และ Rate Limiting |
| [ADR-009](./ADR-009-database-migration-strategy.md) | Database Migration & Deployment | ✅ Accepted | 2025-12-01 | TypeORM Migrations พร้อม Blue-Green Deployment |
| [ADR-015](./ADR-015-deployment-infrastructure.md) | Deployment & Infrastructure Strategy | ✅ Accepted | 2025-12-01 | Docker Compose with Blue-Green Deployment on QNAP |
| [ADR-016](./ADR-016-security-authentication.md) | Security & Authentication Strategy | ✅ Accepted | 2025-12-01 | JWT + bcrypt + OWASP Security Best Practices |
### API & Integration
| ADR | Title | Status | Date | Summary |
| --------------------------------------------------- | ----------------------------- | ----------- | ---------- | ----------------------------------------------------------------------------- |
| [ADR-007](./ADR-007-api-design-error-handling.md) | API Design & Error Handling | ✅ Accepted | 2025-12-01 | Standard REST API with Custom Error Format + NestJS Exception Filters |
| [ADR-008](./ADR-008-email-notification-strategy.md) | Email & Notification Strategy | ✅ Accepted | 2025-12-01 | BullMQ + Redis Queue สำหรับ Multi-channel Notifications (Email, LINE, In-app) |
### Observability
| ADR | Title | Status | Date | Summary |
| --------------------------------------------------- | ----------------------------- | ----------- | ---------- | ------------------------------------------------------------- |
| [ADR-010](./ADR-010-logging-monitoring-strategy.md) | Logging & Monitoring Strategy | ✅ Accepted | 2025-12-01 | Winston Structured Logging พร้อม Future ELK Stack Integration |
### Frontend Architecture
| ADR | Title | Status | Date | Summary |
| ------------------------------------------------ | -------------------------------- | ----------- | ---------- | ----------------------------------------------------- |
| [ADR-011](./ADR-011-nextjs-app-router.md) | Next.js App Router & Routing | ✅ Accepted | 2025-12-01 | App Router with Server Components and Nested Layouts |
| [ADR-012](./ADR-012-ui-component-library.md) | UI Component Library (Shadcn/UI) | ✅ Accepted | 2025-12-01 | Shadcn/UI + Tailwind CSS for Full Component Ownership |
| [ADR-013](./ADR-013-form-handling-validation.md) | Form Handling & Validation | ✅ Accepted | 2025-12-01 | React Hook Form + Zod for Type-Safe Forms |
| [ADR-014](./ADR-014-state-management.md) | State Management Strategy | ✅ Accepted | 2025-12-01 | Zustand for Client State + Server Components |
---
## 🔍 ADR Categories
### 1. Business Logic & Workflows
- **ADR-001:** Unified Workflow Engine - ใช้ JSON DSL แทน Hard-coded routing tables
### 2. Data Integrity & Concurrency
- **ADR-002:** Document Numbering - Double-lock เพื่อป้องกัน Race Condition
- **ADR-003:** File Storage - Two-phase เพื่อ Transaction safety
- **ADR-009:** Database Migration - TypeORM Migrations พร้อม Blue-Green Deployment
### 3. Security & Access Control
- **ADR-004:** RBAC - 4-level scope สำหรับ Fine-grained permissions
### 4. Infrastructure & Performance
- **ADR-005:** Technology Stack - TypeScript ecosystem
- **ADR-006:** Redis - Caching และ Distributed coordination
- **ADR-015:** Deployment - Docker Compose with Blue-Green Deployment
- **ADR-016:** Security - JWT Authentication + OWASP Best Practices
### 5. API & Integration
- **ADR-007:** API Design - REST API with Custom Error Format
- **ADR-008:** Notification - BullMQ Queue สำหรับ Multi-channel notifications
### 6. Observability & Monitoring
- **ADR-010:** Logging - Winston Structured Logging พร้อม Future ELK Stack
### 7. Frontend Architecture
- **ADR-011:** Next.js App Router - Server Components และ Nested Layouts
- **ADR-012:** UI Components - Shadcn/UI + Tailwind CSS
- **ADR-013:** Form Handling - React Hook Form + Zod Validation
- **ADR-014:** State Management - Zustand + Server Components
---
## 📖 How to Read ADRs
### ADR Structure
แต่ละ ADR มีโครงสร้างดังนี้:
1. **Status**: Accepted, Proposed, Deprecated, Superseded
2. **Context**: ปัญหาหรือสถานการณ์ที่ต้องตัดสินใจ
3. **Decision Drivers**: ปัจจัยที่มีผลต่อการตัดสินใจ
4. **Considered Options**: ทางเลือกที่พิจารณา (พร้อม Pros/Cons)
5. **Decision Outcome**: สิ่งที่เลือก และเหตุผล
6. **Consequences**: ผลที่ตามมา (Positive/Negative/Mitigation)
7. **Implementation Details**: รายละเอียดการ Implement (Code examples)
8. **Related ADRs**: ADR อื่นที่เกี่ยวข้อง
### Reading Tips
- เริ่มจาก **Context** เพื่อเข้าใจปัญหา
- ดู **Considered Options** เพื่อเข้าใจ Trade-offs
- อ่าน **Consequences** เพื่อรู้ว่าต้อง Maintain อย่างไร
- ดู **Related ADRs** เพื่อเข้าใจภาพรวม
---
## 🆕 Creating New ADRs
### When to Create an ADR?
สร้าง ADR เมื่อ:
- ✅ เลือก Technology/Framework หลัก
- ✅ ออกแบบ Architecture Pattern สำคัญ
- ✅ แก้ปัญหาซับซ้อนที่มีหลาย Alternatives
- ✅ Trade-offs ที่มีผลกระทบระยะยาว
- ✅ ตัดสินใจที่ยากจะ Revert (Irreversible decisions)
**ไม่ต้องสร้าง ADR สำหรับ:**
- ❌ การเลือก Library เล็กๆ ที่เปลี่ยนได้ง่าย
- ❌ Implementation details ที่ไม่กระทบ Architecture
- ❌ Coding style หรือ Naming conventions
### ADR Template
```markdown
# ADR-XXX: [Title]
**Status:** Proposed
**Date:** YYYY-MM-DD
**Decision Makers:** [Names]
**Related Documents:** [Links]
---
## Context and Problem Statement
[Describe the problem...]
---
## Decision Drivers
- [Driver 1]
- [Driver 2]
---
## Considered Options
### Option 1: [Name]
**Pros:**
- ✅ [Pro 1]
**Cons:**
- ❌ [Con 1]
---
## Decision Outcome
**Chosen Option:** [Option X]
### Rationale
[Why this option...]
---
## Consequences
### Positive
1. ✅ [Impact 1]
### Negative
1. ❌ [Risk 1]
---
## Related ADRs
- [ADR-XXX: Title](./ADR-XXX.md)
```
---
## 🔄 ADR Lifecycle
```mermaid
stateDiagram-v2
[*] --> Proposed: Create new ADR
Proposed --> Accepted: Team agrees
Proposed --> Rejected: Team disagrees
Accepted --> Deprecated: No longer relevant
Accepted --> Superseded: Replaced by new ADR
Deprecated --> [*]
Superseded --> [*]
Rejected --> [*]
```
### Status Definitions
- **Proposed**: รอการ Review และ Approve
- **Accepted**: ผ่านการ Review แล้ว กำลังใช้งาน
- **Deprecated**: เลิกใช้แล้ว แต่ยังอยู่ในระบบ
- **Superseded**: ถูกแทนที่โดย ADR อื่น
- **Rejected**: ไม่ผ่านการ Approve
---
## 📊 ADR Impact Map
```mermaid
graph TB
ADR001[ADR-001<br/>Unified Workflow] --> Corr[Correspondences]
ADR001 --> RFA[RFAs]
ADR001 --> Circ[Circulations]
ADR002[ADR-002<br/>Document Numbering] --> Corr
ADR002 --> RFA
ADR003[ADR-003<br/>File Storage] --> Attach[Attachments]
ADR003 --> Corr
ADR003 --> RFA
ADR004[ADR-004<br/>RBAC] --> Auth[Authentication]
ADR004 --> Guards[Guards]
ADR005[ADR-005<br/>Tech Stack] --> Backend[Backend]
ADR005 --> Frontend[Frontend]
ADR005 --> DB[(Database)]
ADR006[ADR-006<br/>Redis] --> Cache[Caching]
ADR006 --> Lock[Locking]
ADR006 --> Queue[Job Queue]
ADR006 --> ADR002
ADR006 --> ADR004
```
---
## 🔗 Related Documentation
- [System Architecture](../02-architecture/system-architecture.md) - สถาปัตยกรรมระบบโดยรวม
- [Data Model](../02-architecture/data-model.md) - โครงสร้างฐานข้อมูล
- [API Design](../02-architecture/api-design.md) - การออกแบบ API
- [Backend Guidelines](../03-implementation/backend-guidelines.md) - มาตรฐานการพัฒนา Backend
- [Frontend Guidelines](../03-implementation/frontend-guidelines.md) - มาตรฐานการพัฒนา Frontend
---
## 📝 Review Process
### Before Merging
1. สร้าง ADR ใน `specs/05-decisions/ADR-XXX-title.md`
2. Update ADR Index ใน `README.md` นี้
3. Link ADR ไปยัง Related Documents
4. Request Review จากทีม
5. อภิปรายและปรับแก้ตาม Feedback
6. Update Status เป็น "Accepted"
7. Merge to main branch
### Review Checklist
- ☐ Context ชัดเจน เข้าใจปัญหา
- ☐ มี Options อย่างน้อย 2-3 ทางเลือก
- ☐ Pros/Cons ครบถ้วน
- ☐ Decision Rationale มีเหตุผลรองรับ
- ☐ Consequences ระบุทั้งดีและไม่ดี
- ☐ Related ADRs linked ถูกต้อง
- ☐ Code examples (ถ้ามี) อ่านง่าย
---
## 🎯 Best Practices
### Writing Good ADRs
1. **Be Concise:** ไม่เกิน 3-4 หน้า (except code examples)
2. **Focus on "Why":** อธิบายเหตุผลมากกว่า "How"
3. **List Alternatives:** แสดงว่าพิจารณาหลายทางเลือก
4. **Be Honest:** ระบุ Cons และ Risks จริงๆ
5. **Use Diagrams:** Visualize ด้วย Mermaid diagrams
6. **Link References:** ใส่ Link ไปเอกสารอ้างอิง
### Common Mistakes
- ❌ เขียนยาวเกินไป (วนเวียน)
- ❌ ไม่มี Alternatives (แสดงว่าไม่ได้พิจารณา)
- ❌ Consequences ไม่จริงใจ (แต่งว่าดีอย่างเดียว)
- ❌ Implementation details มากเกินไป
- ❌ ไม่ Update เมื่อ Decision เปลี่ยน
---
## 📚 External Resources
- [ADR GitHub Organization](https://adr.github.io/)
- [Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
- [ADR Tools](https://github.com/npryce/adr-tools)
- [Architecture Decision Records in Action](https://www.thoughtworks.com/insights/blog/architecture/architecture-decision-records-in-action)
---
## 📧 Contact
หากมีคำถามเกี่ยวกับ ADRs กรุณาติดต่อ:
- **System Architect:** Nattanin Peancharoen
- **Development Team Lead:** [Name]
---
**Version:** 1.5.0
**Last Review:** 2025-11-30

623
specs/06-tasks/README.md Normal file
View File

@@ -0,0 +1,623 @@
# Development Tasks
**Project:** LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This directory contains detailed development tasks for both **Backend** and **Frontend** development of LCBP3-DMS.
### Backend Tasks (13 tasks)
Comprehensive backend implementation covering:
- Foundation (Database, Auth)
- Core Services (File Storage, Document Numbering, Workflow Engine)
- Business Modules (Correspondence, RFA, Drawing)
- Supporting Services (Search, Notifications, Master Data)
### Frontend Tasks (5+ tasks)
Complete frontend UI development including:
- Setup & Configuration
- Authentication UI
- Layout & Navigation
- Business Module UIs
- Common Reusable Components
**Total Estimated Timeline:** 24-26 weeks for complete MVP
### Task Status Legend
- 🔴 **Not Started** - ยังไม่เริ่มทำ
- 🟡 **In Progress** - กำลังดำเนินการ
- 🟢 **Completed** - เสร็จสมบูรณ์
- ⏸️ **Blocked** - มีสิ่งที่ Block การทำงาน
### Priority Levels
- **P0 (Critical):** ต้องทำก่อน เป็น Foundation
- **P1 (High):** สำคัญมาก Core Business Logic
- **P2 (Medium):** สำคัญปานกลาง Supporting Features
- **P3 (Low):** ทำทีหลังได้ Enhancements
---
## 🗺️ Task Roadmap
```mermaid
graph TB
subgraph "Phase 1: Foundation (P0)"
T001[TASK-BE-001<br/>Database Migrations]
T002[TASK-BE-002<br/>Auth & RBAC]
end
subgraph "Phase 2: Core Infrastructure (P0-P1)"
T013[TASK-BE-013<br/>User Management]
T012[TASK-BE-012<br/>Master Data]
T003[TASK-BE-003<br/>File Storage]
T004[TASK-BE-004<br/>Doc Numbering]
T006[TASK-BE-006<br/>Workflow Engine]
end
subgraph "Phase 3: Business Modules (P1)"
T005[TASK-BE-005<br/>Correspondence]
T007[TASK-BE-007<br/>RFA]
end
subgraph "Phase 4: Supporting Modules (P2)"
T008[TASK-BE-008<br/>Drawing]
T009[TASK-BE-009<br/>Circulation/Transmittal]
T010[TASK-BE-010<br/>Search/Elasticsearch]
end
subgraph "Phase 5: Services (P3)"
T011[TASK-BE-011<br/>Notification/Audit]
end
T001 --> T002
T002 --> T013
T002 --> T012
T002 --> T003
T002 --> T004
T002 --> T006
T013 --> T005
T012 --> T005
T003 --> T005
T004 --> T005
T006 --> T005
T013 --> T007
T012 --> T007
T003 --> T007
T004 --> T007
T006 --> T007
T012 --> T008
T003 --> T008
T004 --> T008
T012 --> T009
T003 --> T009
T006 --> T009
T005 --> T010
T007 --> T010
T002 --> T011
style T001 fill:#ff6b6b
style T002 fill:#ff6b6b
style T013 fill:#feca57
style T012 fill:#feca57
style T003 fill:#feca57
style T004 fill:#feca57
style T006 fill:#ff6b6b
style T005 fill:#feca57
style T007 fill:#feca57
style T008 fill:#48dbfb
style T009 fill:#48dbfb
style T010 fill:#48dbfb
style T011 fill:#a29bfe
```
---
## 📊 Task List
### Phase 1: Foundation (2-3 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| ---------------------------------------------- | --------------------------- | -------- | -------- | -------------- | ------------ |
| [BE-001](./TASK-BE-001-database-migrations.md) | Database Setup & Migrations | P0 | 2-3 days | 🔴 Not Started | None |
| [BE-002](./TASK-BE-002-auth-rbac.md) | Auth & RBAC Module | P0 | 5-7 days | 🔴 Not Started | BE-001 |
### Phase 2: Core Infrastructure (3-4 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| ------------------------------------------------- | -------------------------- | -------- | ---------- | -------------- | -------------- |
| [BE-013](./TASK-BE-013-user-management.md) | User Management | P1 | 5-7 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-012](./TASK-BE-012-master-data-management.md) | Master Data Management | P1 | 6-8 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-003](./TASK-BE-003-file-storage.md) | File Storage (Two-Phase) | P1 | 4-5 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-004](./TASK-BE-004-document-numbering.md) | Document Numbering Service | P1 | 5-6 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-006](./TASK-BE-006-workflow-engine.md) | Workflow Engine | P0 | 10-14 days | 🔴 Not Started | BE-001, BE-002 |
### Phase 3: Business Modules (4-5 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| ------------------------------------------------ | --------------------- | -------- | --------- | -------------- | ---------------------------------- |
| [BE-005](./TASK-BE-005-correspondence-module.md) | Correspondence Module | P1 | 7-10 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
| [BE-007](./TASK-BE-007-rfa-module.md) | RFA Module | P1 | 8-12 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
### Phase 4: Supporting Modules (2-3 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| -------------------------------------------------- | ------------------------- | -------- | -------- | -------------- | -------------------------- |
| [BE-008](./TASK-BE-008-drawing-module.md) | Drawing Module | P2 | 6-8 days | 🔴 Not Started | BE-001~004, BE-012 |
| [BE-009](./TASK-BE-009-circulation-transmittal.md) | Circulation & Transmittal | P2 | 5-7 days | 🔴 Not Started | BE-001~003, BE-006, BE-012 |
| [BE-010](./TASK-BE-010-search-elasticsearch.md) | Search & Elasticsearch | P2 | 4-6 days | 🔴 Not Started | BE-001, BE-005, BE-007 |
### Phase 5: Supporting Services (1 week)
| ID | Task | Priority | Effort | Status | Dependencies |
| --------------------------------------------- | ------------------------ | -------- | -------- | -------------- | -------------- |
| [BE-011](./TASK-BE-011-notification-audit.md) | Notification & Audit Log | P3 | 3-5 days | 🔴 Not Started | BE-001, BE-002 |
---
## 🎨 Frontend Tasks
### Phase 1: Foundation (Weeks 1-2)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------- | --------------------------------- | -------- | -------- | -------------- |
| [TASK-FE-001](./TASK-FE-001-frontend-setup.md) | Frontend Setup & Configuration | P0 | 2-3 days | None |
| [TASK-FE-002](./TASK-FE-002-auth-ui.md) | Authentication & Authorization UI | P0 | 3-4 days | FE-001, BE-002 |
| [TASK-FE-003](./TASK-FE-003-layout-navigation.md) | Layout & Navigation System | P0 | 3-4 days | FE-001, FE-002 |
### Phase 2: Core Components (Week 3)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------- | ------------------------------- | -------- | -------- | ------------ |
| [TASK-FE-005](./TASK-FE-005-common-components.md) | Common Components & Reusable UI | P1 | 3-4 days | FE-001 |
### Phase 3: Business Modules (Weeks 4-8)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------- | ---------------------------- | -------- | -------- | ---------------------- |
| [TASK-FE-004](./TASK-FE-004-correspondence-ui.md) | Correspondence Management UI | P1 | 5-7 days | FE-003, FE-005, BE-005 |
| [TASK-FE-006](./TASK-FE-006-rfa-ui.md) | RFA Management UI | P1 | 5-7 days | FE-003, FE-005, BE-007 |
| [TASK-FE-007](./TASK-FE-007-drawing-ui.md) | Drawing Management UI | P2 | 4-6 days | FE-003, FE-005, BE-008 |
### Phase 4: Supporting Features (Week 9)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------------- | ---------------------------- | -------- | -------- | -------------- |
| [TASK-FE-008](./TASK-FE-008-search-ui.md) | Search & Global Filters | P2 | 3-4 days | FE-003, BE-010 |
| [TASK-FE-009](./TASK-FE-009-dashboard-notifications.md) | Dashboard & Notifications UI | P3 | 3-4 days | FE-003, BE-011 |
### Phase 5: Administration (Weeks 10-11)
| Task | Title | Priority | Effort | Dependencies |
| --------------------------------------------------- | ---------------------------- | -------- | -------- | ------------------------------ |
| [TASK-FE-010](./TASK-FE-010-admin-panel.md) | Admin Panel & Settings UI | P2 | 5-7 days | FE-002, FE-005, BE-012, BE-013 |
| [TASK-FE-011](./TASK-FE-011-workflow-config-ui.md) | Workflow Configuration UI | P2 | 5-7 days | FE-010, BE-006 |
| [TASK-FE-012](./TASK-FE-012-numbering-config-ui.md) | Document Numbering Config UI | P2 | 3-4 days | FE-010, BE-004 |
---
## 📅 Estimated Timeline
### Sprint Planning (2-week sprints)
#### Sprint 1-2: Foundation (4 weeks)
- Week 1-2: Database Migrations (BE-001)
- Week 2-4: Auth & RBAC (BE-002)
- _Milestone:_ User can login and access protected routes
#### Sprint 3-5: Core Infrastructure (6 weeks)
- Week 5-6: User Management (BE-013) + Master Data (BE-012)
- Week 7-8: File Storage (BE-003) + Document Numbering (BE-004)
- Week 9-10: Workflow Engine (BE-006)
- _Milestone:_ Complete infrastructure ready for business modules
#### Sprint 6-8: Business Modules (6 weeks)
- Week 11-14: Correspondence Module (BE-005)
- Week 15-17: RFA Module (BE-007)
- _Milestone:_ Core business documents managed
#### Sprint 9-10: Supporting Modules (4 weeks)
- Week 18-19: Drawing Module (BE-008)
- Week 20: Circulation & Transmittal (BE-009)
- Week 21: Search & Elasticsearch (BE-010)
- _Milestone:_ Complete document ecosystem
#### Sprint 11: Supporting Services (1 week)
- Week 22: Notification & Audit (BE-011)
- _Milestone:_ Full MVP ready
**Total Estimated Time:** ~22 weeks (5.5 months)
---
## 🎯 Task Details
### TASK-BE-001: Database Setup & Migrations
- **Type:** Infrastructure
- **Key Deliverables:**
- TypeORM configuration
- 50+ entity classes
- Migration scripts
- Seed data
- **Why First:** Foundation for all other modules
### TASK-BE-002: Auth & RBAC
- **Type:** Security & Infrastructure
- **Key Deliverables:**
- JWT authentication
- 4-level RBAC with CASL
- Permission guards
- Idempotency interceptor
- **Why Critical:** Required for all protected endpoints
### TASK-BE-003: File Storage (Two-Phase)
- **Type:** Core Service
- **Key Deliverables:**
- Two-phase upload system
- Virus scanning (ClamAV)
- File validation
- Cleanup jobs
- **Related ADR:** [ADR-003](../05-decisions/ADR-003-file-storage-approach.md)
### TASK-BE-004: Document Numbering
- **Type:** Core Service
- **Key Deliverables:**
- Double-lock mechanism (Redis + DB)
- Template-based generator
- Concurrent-safe implementation
- **Related ADR:** [ADR-002](../05-decisions/ADR-002-document-numbering-strategy.md)
### TASK-BE-006: Workflow Engine
- **Type:** Core Infrastructure
- **Key Deliverables:**
- DSL parser และ validator
- DSL parser and validator
- State machine management
- Guard and effect executors
- History tracking
- **Related ADR:** [ADR-001](../05-decisions/ADR-001-unified-workflow-engine.md)
### TASK-BE-005: Correspondence Module
- **Type:** Business Module
- **Key Deliverables:**
- Master-Revision pattern implementation
- CRUD operations with workflow
- Attachment management
- Search & filter
- **Why Critical:** Core business document type
### TASK-BE-006: Workflow Engine
- **Type:** Core Infrastructure
- **Key Deliverables:**
- DSL parser and validator
- State machine management
- Guard and effect executors
- History tracking
- **Related ADR:** [ADR-001](../05-decisions/ADR-001-unified-workflow-engine.md)
### TASK-BE-007: RFA Module
- **Type:** Business Module
- **Key Deliverables:**
- Master-Revision pattern
- RFA Items management
- Approval workflow integration
- Review/Respond actions
- **Why Important:** Critical approval process
### TASK-BE-008: Drawing Module
- **Type:** Supporting Module
- **Key Deliverables:**
- Contract Drawing management
- Shop Drawing with revisions
- Drawing categories and references
- Version control
- **Why Important:** Technical document management
### TASK-BE-009: Circulation & Transmittal
- **Type:** Supporting Module
- **Key Deliverables:**
- Circulation sheet with assignees
- Transmittal with document items
- PDF generation for transmittal
- Workflow integration
- **Why Important:** Internal and external document distribution
### TASK-BE-010: Search & Elasticsearch
- **Type:** Performance Enhancement
- **Key Deliverables:**
- Elasticsearch integration
- Full-text search across documents
- Async indexing via queue
- Advanced filters and aggregations
- **Why Important:** Improved search UX
### TASK-BE-011: Notification & Audit
- **Type:** Supporting Services
- **Key Deliverables:**
- Email and LINE notifications
- In-app notifications
- Audit log recording
- Audit log export
- **Why Important:** User engagement and compliance
---
## 🔗 Dependencies Graph
```
BE-001 (Database)
├── BE-002 (Auth)
│ ├── BE-004 (Doc Numbering)
│ ├── BE-006 (Workflow)
│ └── BE-011 (Notification/Audit)
├── BE-003 (File Storage)
│ ├── BE-005 (Correspondence)
│ ├── BE-007 (RFA)
│ ├── BE-008 (Drawing)
│ └── BE-009 (Circulation/Transmittal)
├── BE-005 (Correspondence)
│ └── BE-010 (Search)
└── BE-007 (RFA)
└── BE-010 (Search)
```
---
## ✅ Definition of Done (DoD)
สำหรับทุก Task ต้องผ่านเกณฑ์ดังนี้:
### Code Quality
- ✅ Code เป็นไปตาม [Backend Guidelines](../03-implementation/backend-guidelines.md)
- ✅ No `any` types (TypeScript Strict Mode)
- ✅ ESLint และ Prettier passed
- ✅ No console.log (use Logger)
### Testing
- ✅ Unit Tests (coverage ≥ 80%)
- ✅ Integration Tests สำหรับ Critical Paths
- ✅ E2E Tests (ถ้ามี)
- ✅ Load Tests สำหรับ Performance-Critical Features
### Documentation
- ✅ API Documentation (Swagger/OpenAPI)
- ✅ Code Comments (JSDoc for public methods)
- ✅ README updated (ถ้าจำเป็น)
### Review
- ✅ Code Review โดยอย่างน้อย 1 คน
- ✅ QA Testing passed
- ✅ No Critical/High bugs
---
## 🚨 Risk Management
### High-Risk Tasks
| Task | Risk | Mitigation |
| ------ | ---------------------------- | ----------------------------------- |
| BE-004 | Race conditions in numbering | Comprehensive concurrent testing |
| BE-006 | Complex DSL parsing | Extensive validation และ testing |
| BE-002 | Security vulnerabilities | Security audit, penetration testing |
### Blockers Tracking
Track potential blockers:
- Redis service availability (for BE-004, BE-002)
- ClamAV service availability (for BE-003)
- External API dependencies (ถ้ามี)
---
## 📚 Related Documentation
### Architecture
- [System Architecture](../02-architecture/system-architecture.md)
- [Data Model](../02-architecture/data-model.md)
- [API Design](../02-architecture/api-design.md)
### Guidelines
- [Backend Guidelines](../03-implementation/backend-guidelines.md)
- [Testing Strategy](../03-implementation/testing-strategy.md)
### Decisions
- [ADR-001: Unified Workflow Engine](../05-decisions/ADR-001-unified-workflow-engine.md)
- [ADR-002: Document Numbering Strategy](../05-decisions/ADR-002-document-numbering-strategy.md)
- [ADR-003: Two-Phase File Storage](../05-decisions/ADR-003-file-storage-approach.md)
- [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
- [ADR-005: Technology Stack](../05-decisions/ADR-005-technology-stack.md)
- [ADR-006: Redis Caching Strategy](../05-decisions/ADR-006-redis-caching-strategy.md)
---
## 📝 How to Use This Directory
### For Developers
1. **เลือก Task:** เริ่มจาก P0 dependencies ก่อน
2. **อ่าน Task File:** เข้าใจ Objectives และ Acceptance Criteria
3. **ติดตาม Implementation Steps:** Follow code examples
4. **เขียน Tests:** ตามที่ระบุใน Testing section
5. **Update Status:** ให้ทีมทราบความคืบหน้า
### For Project Managers
1. **Track Progress:** ใช้ Task List และ Status
2. **Monitor Dependencies:** ตรวจสอบว่า Blocked หรือไม่
3. **Estimate Timeline:** ใช้ Effort estimates
4. **Review Risks:** ติดตาม High-Risk tasks
---
## 🎬 Getting Started
```bash
# 1. Clone repository
git clone https://git.np-dms.work/lcbp3/backend.git
cd backend
# 2. Install dependencies
npm install
# 3. Setup environment
cp .env.example .env
# Edit .env with your configuration
# 4. Start database (Docker)
docker-compose up -d mariadb redis
# 5. Run migrations
npm run migration:run
# 6. Run seed
npm run seed
# 7. Start development server
npm run start:dev
```
---
## <20> Future Enhancements (Post-MVP)
The following features are **NOT required for MVP** but may be considered for future phases based on user feedback and business priorities:
### Phase 6: Reports & Analytics (Optional - P3)
**Estimated Effort:** 3-4 weeks
| Feature | Description | Priority | Effort |
| --------------------- | ---------------------------------- | -------- | -------- |
| Dashboard System | Real-time charts and metrics | P3 | 5-7 days |
| Standard Reports | Document status, workflow progress | P3 | 4-5 days |
| Custom Report Builder | User-defined report templates | P3 | 6-8 days |
| Export to Excel/PDF | Report export functionality | P3 | 3-4 days |
| Data Analytics | Trend analysis and insights | P3 | 5-6 days |
**Business Value:**
- Management visibility into project status
- Performance metrics and KPIs
- Compliance reporting
### Phase 7: Advanced Features (Optional - P3)
**Estimated Effort:** 2-3 weeks
| Feature | Description | Priority | Effort |
| ----------------------- | ----------------------------- | -------- | -------- |
| Document Templates | Letter and email templates | P3 | 3-4 days |
| Advanced Rate Limiting | Per-user quotas, throttling | P2 | 2-3 days |
| Structured Logging | Winston/Pino integration | P3 | 2-3 days |
| APM Integration | New Relic, Datadog monitoring | P3 | 3-4 days |
| Email Queue Retry Logic | Failed email retry mechanism | P2 | 2-3 days |
| Bulk Operations | Bulk update, bulk approve | P3 | 4-5 days |
**Business Value:**
- Improved operational efficiency
- Better system observability
- Enhanced user experience
### Phase 8: Mobile & Offline Support (Optional - P2)
**Estimated Effort:** 4-6 weeks
| Feature | Description | Priority | Effort |
| -------------------------- | ------------------------ | -------- | --------- |
| Mobile App (React Native) | iOS and Android apps | P2 | 3-4 weeks |
| Offline-First Architecture | PWA with service workers | P2 | 2-3 weeks |
| Mobile Push Notifications | Firebase Cloud Messaging | P2 | 1 week |
| Mobile Document Scanner | OCR integration | P3 | 1-2 weeks |
**Business Value:**
- Field access for construction sites
- Work offline, sync later
- Real-time mobile notifications
### Phase 9: Integration & API (Optional - P2)
**Estimated Effort:** 2-3 weeks
| Feature | Description | Priority | Effort |
| ------------------------ | ----------------------------- | -------- | --------- |
| REST API Documentation | OpenAPI 3.0 spec | P2 | 3-4 days |
| Webhook System | External system notifications | P2 | 4-5 days |
| Third-party Integrations | Email, Calendar, Drive | P3 | 1-2 weeks |
| GraphQL API | Alternative to REST | P3 | 1-2 weeks |
| API Versioning | v1, v2 support | P2 | 2-3 days |
**Business Value:**
- Integration with existing systems
- Extensibility for future needs
- Developer-friendly APIs
### Decision Criteria for Future Enhancements
Add these features when:
- ✅ MVP is stable and in production
- ✅ User feedback indicates need
- ✅ Business case is justified
- ✅ Resources are available
- ✅ Does not compromise core functionality
**Do NOT add these features if:**
- ❌ MVP is not yet complete
- ❌ Core features have bugs
- ❌ Team is understaffed
- ❌ No clear business value
---
## <20>📧 Contact & Support
- **Backend Team Lead:** [Name]
- **System Architect:** Nattanin Peancharoen
- **Project Channel:** Slack #lcbp3-backend
---
**Version:** 1.5.0
**Last Updated:** 2025-11-30

View File

@@ -0,0 +1,263 @@
# Task: Database Setup & Migrations
**Status:** Not Started
**Priority:** P0 (Critical - Foundation)
**Estimated Effort:** 2-3 days
**Dependencies:** None
**Owner:** Backend Team
---
## 📋 Overview
ตั้งค่า Database schema สำหรับ LCBP3-DMS โดยใช้ TypeORM Migrations และ Seeding data
---
## 🎯 Objectives
- ✅ สร้าง Initial Database Schema
- ✅ Setup TypeORM Configuration
- ✅ Create Migration System
- ✅ Setup Seed Data
- ✅ Verify Database Structure
---
## 📝 Acceptance Criteria
1. **Database Schema:**
- ✅ ทุกตารางถูกสร้างตาม Data Dictionary v1.4.5
- ✅ Foreign Keys ถูกต้องครบถ้วน
- ✅ Indexes ครบตาม Specification
- ✅ Virtual Columns สำหรับ JSON fields
2. **Migrations:**
- ✅ Migration files เรียงลำดับถูกต้อง
- ✅ สามารถ `migrate:up` และ `migrate:down` ได้
- ✅ ไม่มี Data loss เมื่อ rollback
3. **Seed Data:**
- ✅ Master data (Organizations, Project, Roles, Permissions)
- ✅ Test users สำหรับแต่ละ Role
- ✅ Sample data สำหรับ Development
---
## 🛠️ Implementation Steps
### 1. TypeORM Configuration
```typescript
// File: backend/src/config/database.config.ts
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../database/migrations/*{.ts,.js}'],
migrationsRun: false, // Manual migration
synchronize: false, // ห้ามใช้ใน Production
logging: process.env.NODE_ENV === 'development',
};
```
### 2. Create Entity Classes
**Core Entities:**
- `Organization` (organizations)
- `Project` (projects)
- `Contract` (contracts)
- `User` (users)
- `Role` (roles)
- `Permission` (permissions)
- `UserAssignment` (user_assignments)
**Document Entities:**
- `Correspondence` (correspondences)
- `CorrespondenceRevision` (correspondence_revisions)
- `Rfa` (rfas)
- `RfaRevision` (rfa_revisions)
- `ShopDrawing` (shop_drawings)
- `ShopDrawingRevision` (shop_drawing_revisions)
**Supporting Entities:**
- `WorkflowDefinition` (workflow_definitions)
- `WorkflowInstance` (workflow_instances)
- `WorkflowHistory` (workflow_history)
- `DocumentNumberFormat` (document_number_formats)
- `DocumentNumberCounter` (document_number_counters)
- `Attachment` (attachments)
- `AuditLog` (audit_logs)
### 3. Create Initial Migration
```bash
npm run migration:generate -- -n InitialSchema
```
**Migration File Structure:**
```typescript
// File: backend/src/database/migrations/1701234567890-InitialSchema.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1701234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create organizations table
await queryRunner.query(`
CREATE TABLE organizations (
id INT PRIMARY KEY AUTO_INCREMENT,
organization_code VARCHAR(20) NOT NULL UNIQUE,
organization_name VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
INDEX idx_org_code (organization_code),
INDEX idx_org_active (is_active, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Continue with other tables...
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS organizations;`);
// Rollback other tables...
}
}
```
### 4. Create Seed Script
```typescript
// File: backend/src/database/seeds/run-seed.ts
import { DataSource } from 'typeorm';
import { seedOrganizations } from './organization.seed';
import { seedRoles } from './role.seed';
import { seedUsers } from './user.seed';
async function runSeeds() {
const dataSource = new DataSource(databaseConfig);
await dataSource.initialize();
try {
console.log('🌱 Seeding database...');
await seedOrganizations(dataSource);
await seedRoles(dataSource);
await seedUsers(dataSource);
console.log('✅ Seeding completed!');
} catch (error) {
console.error('❌ Seeding failed:', error);
} finally {
await dataSource.destroy();
}
}
runSeeds();
```
---
## ✅ Testing & Verification
### 1. Migration Testing
```bash
# Run migrations
npm run migration:run
# Verify tables created
mysql -u root -p lcbp3_dev -e "SHOW TABLES;"
# Rollback one migration
npm run migration:revert
# Re-run migrations
npm run migration:run
```
### 2. Seed Data Verification
```bash
# Run seed
npm run seed
# Verify data
mysql -u root -p lcbp3_dev -e "SELECT * FROM organizations;"
mysql -u root -p lcbp3_dev -e "SELECT * FROM roles;"
mysql -u root -p lcbp3_dev -e "SELECT * FROM users;"
```
### 3. Schema Validation
```sql
-- Check Foreign Keys
SELECT
TABLE_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME
FROM
INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE
TABLE_SCHEMA = 'lcbp3_dev'
AND REFERENCED_TABLE_NAME IS NOT NULL;
-- Check Indexes
SELECT
TABLE_NAME, INDEX_NAME, COLUMN_NAME
FROM
INFORMATION_SCHEMA.STATISTICS
WHERE
TABLE_SCHEMA = 'lcbp3_dev'
ORDER BY
TABLE_NAME, INDEX_NAME;
```
---
## 📚 Related Documents
- [Data Dictionary v1.4.5](../../docs/4_Data_Dictionary_V1_4_5.md)
- [SQL Schema](../../docs/8_lcbp3_v1_4_5.sql)
- [Data Model](../02-architecture/data-model.md)
---
## 📦 Deliverables
- [ ] TypeORM configuration file
- [ ] Entity classes (50+ entities)
- [ ] Initial migration file
- [ ] Seed scripts (organizations, roles, users)
- [ ] Migration test script
- [ ] Documentation: How to run migrations
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------- | ------ | ------------------------------------------- |
| Migration errors | High | Test on dev DB first, backup before migrate |
| Missing indexes | Medium | Review Data Dictionary carefully |
| Seed data conflicts | Low | Use `INSERT IGNORE` or check existing |
---
## 📌 Notes
- ใช้ `utf8mb4_unicode_ci` สำหรับ Thai language support
- ตรวจสอบ Virtual Columns สำหรับ JSON indexing
- ใช้ `@VersionColumn()` สำหรับ Optimistic Locking tables

View File

@@ -0,0 +1,427 @@
# Task: Common Module - Auth & Security
**Status:** Not Started
**Priority:** P0 (Critical - Foundation)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001 (Database)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Common Module ที่รวม Authentication, Authorization, Guards, Interceptors, และ Utility Services
---
## 🎯 Objectives
- ✅ JWT Authentication System
- ✅ 4-Level RBAC with CASL
- ✅ Custom Guards และ Decorators
- ✅ Idempotency Interceptor
- ✅ Rate Limiting
- ✅ Input Validation Framework
---
## 📝 Acceptance Criteria
1. **Authentication:**
- ✅ Login with username/password returns JWT
- ✅ Token refresh mechanism works
- ✅ Token revocation supported
- ✅ Password hashing with bcrypt
2. **Authorization:**
- ✅ RBAC Guards ตรวจสอบ 4 levels (Global/Org/Project/Contract)
- ✅ Permission cache ใน Redis (TTL: 30min)
- ✅ CASL Ability Factory working
3. **Security:**
- ✅ Rate limiting per user/IP
- ✅ Idempotency-Key validation
- ✅ Input sanitization
- ✅ CORS configuration
---
## 🛠️ Implementation Steps
### 1. Auth Module
```typescript
// File: backend/src/common/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '8h' },
}),
],
providers: [AuthService, JwtStrategy, LocalStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
```
```typescript
// File: backend/src/common/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
private redis: Redis
) {}
async login(loginDto: LoginDto): Promise<AuthResponse> {
const user = await this.validateUser(loginDto.username, loginDto.password);
const payload = {
sub: user.user_id,
username: user.username,
organization_id: user.organization_id,
};
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
// Store refresh token in Redis
await this.redis.set(
`refresh_token:${user.user_id}`,
refreshToken,
'EX',
7 * 24 * 3600
);
return {
access_token: accessToken,
refresh_token: refreshToken,
user: this.sanitizeUser(user),
};
}
async validateUser(username: string, password: string): Promise<User> {
const user = await this.userService.findByUsername(username);
if (!user || !user.is_active) {
throw new UnauthorizedException('Invalid credentials');
}
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
async refreshToken(refreshToken: string): Promise<AuthResponse> {
// Verify and refresh token
}
async logout(userId: number): Promise<void> {
// Revoke tokens
await this.redis.del(`refresh_token:${userId}`);
await this.redis.del(`user:${userId}:permissions`);
}
}
```
### 2. RBAC Guards
```typescript
// File: backend/src/common/guards/permission.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory } from '../ability/ability.factory';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory,
private redis: Redis
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const permission = this.reflector.get<string>(
'permission',
context.getHandler()
);
if (!permission) {
return true; // No permission required
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check cache first
let ability = await this.getCachedAbility(user.sub);
if (!ability) {
ability = await this.abilityFactory.createForUser(user);
await this.cacheAbility(user.sub, ability);
}
const [action, subject] = permission.split('.');
const resource = this.getResource(request);
return ability.can(action, subject, resource);
}
private async getCachedAbility(userId: number): Promise<any> {
const cached = await this.redis.get(`user:${userId}:permissions`);
return cached ? JSON.parse(cached) : null;
}
private async cacheAbility(userId: number, ability: any): Promise<void> {
await this.redis.set(
`user:${userId}:permissions`,
JSON.stringify(ability.rules),
'EX',
1800 // 30 minutes
);
}
}
```
### 3. Custom Decorators
```typescript
// File: backend/src/common/decorators/require-permission.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const RequirePermission = (permission: string) =>
SetMetadata('permission', permission);
// Usage:
// @RequirePermission('correspondence.create')
```
```typescript
// File: backend/src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);
// Usage:
// async create(@CurrentUser() user: User) {}
```
### 4. Idempotency Interceptor
```typescript
// File: backend/src/common/interceptors/idempotency.interceptor.ts
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(private redis: Redis) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const idempotencyKey = request.headers['idempotency-key'];
// Only apply to POST/PUT/DELETE
if (!['POST', 'PUT', 'DELETE'].includes(request.method)) {
return next.handle();
}
if (!idempotencyKey) {
throw new BadRequestException('Idempotency-Key header required');
}
// Check for cached result
const cacheKey = `idempotency:${idempotencyKey}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return of(JSON.parse(cached)); // Return previous result
}
// Execute and cache result
return next.handle().pipe(
tap(async (response) => {
await this.redis.set(
cacheKey,
JSON.stringify(response),
'EX',
86400 // 24 hours
);
})
);
}
}
```
### 5. Rate Limiting
```typescript
// File: backend/src/common/guards/rate-limit.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class RateLimitGuard extends ThrottlerGuard {
protected async getTracker(req: any): Promise<string> {
// Use user ID if authenticated, otherwise IP
return req.user?.sub || req.ip;
}
protected async getLimit(context: ExecutionContext): Promise<number> {
// Different limits per role
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) return 100; // Anonymous
switch (user.role) {
case 'admin':
return 5000;
case 'document_control':
return 2000;
case 'editor':
return 1000;
default:
return 500;
}
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
// File: backend/src/common/auth/auth.service.spec.ts
describe('AuthService', () => {
it('should login with valid credentials', async () => {
const result = await service.login({
username: 'testuser',
password: 'password123',
});
expect(result.access_token).toBeDefined();
expect(result.refresh_token).toBeDefined();
});
it('should throw error with invalid credentials', async () => {
await expect(
service.login({
username: 'testuser',
password: 'wrongpassword',
})
).rejects.toThrow(UnauthorizedException);
});
});
```
### 2. Integration Tests
```bash
# Test login endpoint
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "password123"}'
# Test protected endpoint
curl http://localhost:3000/projects \
-H "Authorization: Bearer <access_token>"
# Test permission guard
curl -X POST http://localhost:3000/correspondences \
-H "Authorization: Bearer <viewer_token>" \
-d '{}' # Should return 403
```
### 3. RBAC Testing
```typescript
describe('PermissionGuard', () => {
it('should allow global admin to access everything', async () => {
const canAccess = await guard.canActivate(
mockContext({
user: globalAdmin,
permission: 'correspondence.create',
})
);
expect(canAccess).toBe(true);
});
it('should deny viewer from creating', async () => {
const canAccess = await guard.canActivate(
mockContext({
user: viewer,
permission: 'correspondence.create',
})
);
expect(canAccess).toBe(false);
});
});
```
---
## 📚 Related Documents
- [Backend Guidelines - Security](../03-implementation/backend-guidelines.md#security)
- [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
- [ADR-006: Redis Caching Strategy](../05-decisions/ADR-006-redis-caching-strategy.md)
---
## 📦 Deliverables
- [ ] AuthModule (login, refresh, logout)
- [ ] JWT Strategy
- [ ] Permission Guard with CASL
- [ ] Custom Decorators (@RequirePermission, @CurrentUser)
- [ ] Idempotency Interceptor
- [ ] Rate Limiting Guard
- [ ] Unit Tests (80% coverage)
- [ ] Integration Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------- | -------- | -------------------------------------- |
| JWT secret exposure | Critical | Use strong secret, rotate periodically |
| Redis cache miss | Medium | Fallback to DB query |
| Rate limit bypass | Medium | Multiple tracking (IP + User) |
| RBAC complexity | High | Comprehensive testing |
---
## 📌 Notes
- JWT secret must be 32+ characters
- Refresh tokens expire after 7 days
- Permission cache expires after 30 minutes
- Rate limits differ by role (see RateLimitGuard)

View File

@@ -0,0 +1,470 @@
# Task: File Storage Service (Two-Phase)
**Status:** Not Started
**Priority:** P1 (High)
**Estimated Effort:** 4-5 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง FileStorageService ที่ใช้ Two-Phase Storage Pattern (Temp → Permanent) พร้อม Virus Scanning และ File Validation
---
## 🎯 Objectives
- ✅ Two-Phase Upload System
- ✅ Virus Scanning Integration (ClamAV)
- ✅ File Type Validation
- ✅ Automated Cleanup Job
- ✅ File Metadata Management
---
## 📝 Acceptance Criteria
1. **Phase 1 - Temp Upload:**
- ✅ Upload file → Scan virus → Save to temp/
- ✅ Generate temp_id and return to client
- ✅ Set expiration (24 hours)
- ✅ Calculate SHA-256 checksum
2. **Phase 2 - Commit:**
- ✅ Move temp file → permanent/{YYYY}/{MM}/
- ✅ Update attachment record (is_temporary=false)
- ✅ Link to parent entity (correspondence, rfa, etc.)
- ✅ Transaction-safe (rollback on error)
3. **Cleanup:**
- ✅ Cron job runs every 6 hours
- ✅ Delete expired temp files
- ✅ Delete orphan files (no DB record)
---
## 🛠️ Implementation Steps
### 1. File Storage Service
```typescript
// File: backend/src/common/file-storage/file-storage.service.ts
import { Injectable } from '@nestjs/common';
import * as fs from 'fs-extra';
import * as path from 'path';
import { createHash } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class FileStorageService {
private readonly TEMP_DIR: string;
private readonly PERMANENT_DIR: string;
private readonly MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
constructor(
private config: ConfigService,
private virusScanner: VirusScannerService,
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>
) {
this.TEMP_DIR = path.join(config.get('STORAGE_PATH'), 'temp');
this.PERMANENT_DIR = path.join(config.get('STORAGE_PATH'), 'permanent');
this.ensureDirectories();
}
async uploadToTemp(
file: Express.Multer.File,
userId: number
): Promise<UploadResult> {
// 1. Validate file
this.validateFile(file);
// 2. Virus scan
const scanResult = await this.virusScanner.scan(file.buffer);
if (scanResult.isInfected) {
throw new BadRequestException(`Virus detected: ${scanResult.virusName}`);
}
// 3. Generate identifiers
const tempId = uuidv4();
const storedFilename = `${tempId}_${this.sanitizeFilename(
file.originalname
)}`;
const tempPath = path.join(this.TEMP_DIR, storedFilename);
// 4. Calculate checksum
const checksum = this.calculateChecksum(file.buffer);
// 5. Save to temp directory
await fs.writeFile(tempPath, file.buffer);
// 6. Create attachment record
const attachment = await this.attachmentRepo.save({
original_filename: file.originalname,
stored_filename: storedFilename,
file_path: tempPath,
mime_type: file.mimetype,
file_size: file.size,
checksum,
is_temporary: true,
temp_id: tempId,
expires_at: new Date(Date.now() + 24 * 3600 * 1000), // 24h
uploaded_by_user_id: userId,
});
return {
temp_id: tempId,
filename: file.originalname,
size: file.size,
mime_type: file.mimetype,
expires_at: attachment.expires_at,
};
}
async commitFiles(
tempIds: string[],
entityId: number,
entityType: string,
manager: EntityManager
): Promise<Attachment[]> {
const commitedAttachments = [];
for (const tempId of tempIds) {
// 1. Get temp attachment
const tempAttachment = await manager.findOne(Attachment, {
where: { temp_id: tempId, is_temporary: true },
});
if (!tempAttachment) {
throw new NotFoundException(`Temp file not found: ${tempId}`);
}
if (tempAttachment.expires_at < new Date()) {
throw new BadRequestException(`Temp file expired: ${tempId}`);
}
// 2. Generate permanent path
const now = new Date();
const year = now.getFullYear().toString();
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const permanentDir = path.join(this.PERMANENT_DIR, year, month);
await fs.ensureDir(permanentDir);
const permanentFilename = `${uuidv4()}_${
tempAttachment.original_filename
}`;
const permanentPath = path.join(permanentDir, permanentFilename);
// 3. Move file (atomic operation)
await fs.move(tempAttachment.file_path, permanentPath, {
overwrite: false,
});
// 4. Update attachment record
await manager.update(
Attachment,
{ id: tempAttachment.id },
{
file_path: permanentPath,
stored_filename: permanentFilename,
is_temporary: false,
temp_id: null,
expires_at: null,
}
);
commitedAttachments.push({ ...tempAttachment, file_path: permanentPath });
}
return commitedAttachments;
}
private validateFile(file: Express.Multer.File): void {
// File type validation
const allowedTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'image/png',
'image/jpeg',
'application/zip',
];
if (!allowedTypes.includes(file.mimetype)) {
throw new BadRequestException('Invalid file type');
}
// Size validation
if (file.size > this.MAX_FILE_SIZE) {
throw new BadRequestException('File too large (max 50MB)');
}
// Magic number validation
this.validateMagicNumber(file.buffer, file.mimetype);
}
private validateMagicNumber(buffer: Buffer, mimetype: string): void {
const signatures = {
'application/pdf': [0x25, 0x50, 0x44, 0x46], // %PDF
'image/png': [0x89, 0x50, 0x4e, 0x47], // PNG
'image/jpeg': [0xff, 0xd8, 0xff], // JPEG
};
const signature = signatures[mimetype];
if (signature) {
for (let i = 0; i < signature.length; i++) {
if (buffer[i] !== signature[i]) {
throw new BadRequestException('File content does not match type');
}
}
}
}
private calculateChecksum(buffer: Buffer): string {
return createHash('sha256').update(buffer).digest('hex');
}
private sanitizeFilename(filename: string): string {
return filename.replace(/[^a-zA-Z0-9._-]/g, '_');
}
private async ensureDirectories(): Promise<void> {
await fs.ensureDir(this.TEMP_DIR);
await fs.ensureDir(this.PERMANENT_DIR);
}
}
```
### 2. Virus Scanner Service
```typescript
// File: backend/src/common/file-storage/virus-scanner.service.ts
import { Injectable } from '@nestjs/common';
import NodeClam from 'clamscan';
@Injectable()
export class VirusScannerService {
private clamScan: NodeClam;
async onModuleInit() {
this.clamScan = await new NodeClam().init({
clamdscan: {
host: process.env.CLAMAV_HOST || 'localhost',
port: process.env.CLAMAV_PORT || 3310,
},
});
}
async scan(buffer: Buffer): Promise<ScanResult> {
const { isInfected, viruses } = await this.clamScan.scanStream(buffer);
return {
isInfected,
virusName: viruses.length > 0 ? viruses[0] : null,
};
}
}
```
### 3. Cleanup Job
```typescript
// File: backend/src/common/file-storage/file-cleanup.service.ts
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class FileCleanupService {
constructor(
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private logger: Logger
) {}
@Cron('0 */6 * * *') // Every 6 hours
async cleanupExpiredFiles(): Promise<void> {
this.logger.log('Starting expired file cleanup...');
const expiredFiles = await this.attachmentRepo.find({
where: {
is_temporary: true,
expires_at: LessThan(new Date()),
},
});
let deleted = 0;
for (const file of expiredFiles) {
try {
// Delete physical file
await fs.remove(file.file_path);
// Delete DB record
await this.attachmentRepo.remove(file);
deleted++;
} catch (error) {
this.logger.error(`Failed to delete file ${file.temp_id}:`, error);
}
}
this.logger.log(`Cleaned up ${deleted} expired files`);
}
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async cleanupOrphanFiles(): Promise<void> {
// Find files in filesystem without DB records
this.logger.log('Starting orphan file cleanup...');
// Implementation...
}
}
```
### 4. Controller
```typescript
// File: backend/src/common/file-storage/file-storage.controller.ts
@Controller('attachments')
@UseGuards(JwtAuthGuard)
export class FileStorageController {
constructor(private fileStorage: FileStorageService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: User
): Promise<UploadResult> {
return this.fileStorage.uploadToTemp(file, user.user_id);
}
@Get('temp/:tempId/download')
async downloadTemp(@Param('tempId') tempId: string, @Res() res: Response) {
const attachment = await this.attachmentRepo.findOne({
where: { temp_id: tempId, is_temporary: true },
});
if (!attachment) {
throw new NotFoundException('File not found');
}
res.download(attachment.file_path, attachment.original_filename);
}
@Delete('temp/:tempId')
async deleteTempFile(@Param('tempId') tempId: string): Promise<void> {
// Delete temp file
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('FileStorageService', () => {
it('should upload file to temp successfully', async () => {
const mockFile = createMockFile('test.pdf', 'application/pdf');
const result = await service.uploadToTemp(mockFile, 1);
expect(result.temp_id).toBeDefined();
expect(result.expires_at).toBeDefined();
});
it('should reject infected files', async () => {
virusScanner.scan = jest.fn().mockResolvedValue({
isInfected: true,
virusName: 'EICAR-Test-File',
});
const mockFile = createMockFile('virus.exe', 'application/octet-stream');
await expect(service.uploadToTemp(mockFile, 1)).rejects.toThrow(
'Virus detected'
);
});
it('should commit temp files to permanent', async () => {
const tempIds = ['temp-id-1', 'temp-id-2'];
const committed = await service.commitFiles(
tempIds,
1,
'correspondence',
manager
);
expect(committed).toHaveLength(2);
expect(committed[0].is_temporary).toBe(false);
});
});
```
### 2. Integration Tests
```bash
# Upload file
curl -X POST http://localhost:3000/attachments/upload \
-H "Authorization: Bearer <token>" \
-F "file=@test.pdf"
# Response: { "temp_id": "...", "expires_at": "..." }
# Create correspondence with temp file
curl -X POST http://localhost:3000/correspondences \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"title": "Test",
"project_id": 1,
"temp_file_ids": ["<temp_id>"]
}'
```
---
## 📚 Related Documents
- [ADR-003: Two-Phase File Storage](../05-decisions/ADR-003-file-storage-approach.md)
- [Backend Guidelines - File Storage](../03-implementation/backend-guidelines.md#file-storage)
---
## 📦 Deliverables
- [ ] FileStorageService
- [ ] VirusScannerService (ClamAV integration)
- [ ] FileCleanupService (Cron jobs)
- [ ] FileStorageController
- [ ] AttachmentEntity
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------- | -------- | -------------------------------- |
| ClamAV service down | High | Queue scans, allow bypass in dev |
| Disk space full | Critical | Monitoring + alerts |
| File move failure | Medium | Atomic operations + retry logic |
| Orphan files | Low | Cleanup job + monitoring |
---
## 📌 Notes
- ClamAV requires separate Docker container
- Temp files expire after 24 hours
- Cleanup job runs every 6 hours
- Maximum file size: 50MB
- Supported types: PDF, DOCX, XLSX, PNG, JPEG, ZIP

View File

@@ -0,0 +1,476 @@
# Task: Document Numbering Service
**Status:** Not Started
**Priority:** P1 (High - Critical for Documents)
**Estimated Effort:** 5-6 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง DocumentNumberingService ที่ใช้ Double-Lock mechanism (Redis + DB Optimistic Lock) สำหรับการสร้างเลขที่เอกสารอัตโนมัติ
---
## 🎯 Objectives
- ✅ Template-Based Number Generation
- ✅ Double-Lock Protection (Redis + DB)
- ✅ Concurrent-Safe (No duplicate numbers)
- ✅ Support Disciplines
- ✅ Year-Based Reset
---
## 📝 Acceptance Criteria
1. **Number Generation:**
- ✅ Generate unique sequential numbers
- ✅ Support format: `{ORG}-{TYPE}-{DISCIPLINE}-{YEAR}-{SEQ:4}`
- ✅ No duplicates even with 100+ concurrent requests
- ✅ Generate within 100ms (p90)
2. **Lock Mechanism:**
- ✅ Redis lock acquired (TTL: 3 seconds)
- ✅ DB optimistic lock with version column
- ✅ Retry on conflict (3 times max)
- ✅ Exponential backoff
3. **Format Templates:**
- ✅ Configure per Project/Type
- ✅ Support all token types
- ✅ Validate format before use
---
## 🛠️ Implementation Steps
### 1. Entity - Document Number Format
```typescript
// File: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
@Entity('document_number_formats')
export class DocumentNumberFormat {
@PrimaryGeneratedColumn()
id: number;
@Column()
project_id: number;
@Column()
correspondence_type_id: number;
@Column({ length: 255 })
format_template: string;
// Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}'
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType: CorrespondenceType;
}
```
### 2. Entity - Document Number Counter
```typescript
// File: backend/src/modules/document-numbering/entities/document-number-counter.entity.ts
@Entity('document_number_counters')
export class DocumentNumberCounter {
@PrimaryColumn()
project_id: number;
@PrimaryColumn()
originator_organization_id: number;
@PrimaryColumn()
correspondence_type_id: number;
@PrimaryColumn({ default: 0 })
discipline_id: number;
@PrimaryColumn()
current_year: number;
@Column({ default: 0 })
last_number: number;
@VersionColumn() // Optimistic Lock
version: number;
@UpdateDateColumn()
updated_at: Date;
}
```
### 3. Numbering Service
```typescript
// File: backend/src/modules/document-numbering/document-numbering.service.ts
import Redlock from 'redlock';
interface NumberingContext {
projectId: number;
organizationId: number;
typeId: number;
disciplineId?: number;
year?: number;
}
@Injectable()
export class DocumentNumberingService {
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
@InjectRepository(CorrespondenceType)
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(Discipline)
private disciplineRepo: Repository<Discipline>,
private redlock: Redlock,
private logger: Logger
) {}
async generateNextNumber(context: NumberingContext): Promise<string> {
const year = context.year || new Date().getFullYear();
const disciplineId = context.disciplineId || 0;
// Build Redis lock key
const lockKey = this.buildLockKey(
context.projectId,
context.organizationId,
context.typeId,
disciplineId,
year
);
// Retry logic with exponential backoff
return this.retryWithBackoff(
async () =>
await this.generateNumberWithLock(lockKey, context, year, disciplineId),
3,
200
);
}
private async generateNumberWithLock(
lockKey: string,
context: NumberingContext,
year: number,
disciplineId: number
): Promise<string> {
// Step 1: Acquire Redis lock
const lock = await this.redlock.acquire([lockKey], 3000); // 3 sec TTL
try {
// Step 2: Get or create counter
let counter = await this.counterRepo.findOne({
where: {
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
},
});
if (!counter) {
// Initialize new counter
counter = this.counterRepo.create({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
last_number: 0,
version: 0,
});
await this.counterRepo.save(counter);
}
const currentVersion = counter.version;
const nextNumber = counter.last_number + 1;
// Step 3: Update counter with Optimistic Lock
const result = await this.counterRepo
.createQueryBuilder()
.update(DocumentNumberCounter)
.set({
last_number: nextNumber,
})
.where({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
version: currentVersion, // Optimistic lock check
})
.execute();
if (result.affected === 0) {
throw new ConflictException('Counter version conflict - retrying...');
}
// Step 4: Format number
const formattedNumber = await this.formatNumber({
projectId: context.projectId,
typeId: context.typeId,
organizationId: context.organizationId,
disciplineId,
year,
sequenceNumber: nextNumber,
});
this.logger.log(`Generated number: ${formattedNumber}`);
return formattedNumber;
} finally {
// Step 5: Release lock
await lock.release();
}
}
private async formatNumber(data: any): Promise<string> {
// Get format template
const format = await this.formatRepo.findOne({
where: {
project_id: data.projectId,
correspondence_type_id: data.typeId,
},
});
if (!format) {
throw new NotFoundException('Document number format not found');
}
// Parse and replace tokens
let result = format.format_template;
const tokens = await this.buildTokens(data);
for (const [token, value] of Object.entries(tokens)) {
result = result.replace(token, value);
}
return result;
}
private async buildTokens(data: any): Promise<Record<string, string>> {
const org = await this.orgRepo.findOne({
where: { id: data.organizationId },
});
const type = await this.typeRepo.findOne({ where: { id: data.typeId } });
let discipline = null;
if (data.disciplineId > 0) {
discipline = await this.disciplineRepo.findOne({
where: { id: data.disciplineId },
});
}
return {
'{ORG_CODE}': org?.organization_code || 'ORG',
'{TYPE_CODE}': type?.type_code || 'TYPE',
'{DISCIPLINE_CODE}': discipline?.discipline_code || 'GEN',
'{YEAR}': data.year.toString(),
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
'{SEQ:5}': data.sequenceNumber.toString().padStart(5, '0'),
};
}
private buildLockKey(...parts: Array<number | string>): string {
return `doc_num:${parts.join(':')}`;
}
private async retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number,
initialDelay: number
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (!(error instanceof ConflictException) || attempt === maxRetries) {
throw error;
}
lastError = error;
const delay = initialDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
}
}
throw lastError;
}
}
```
### 4. Module
```typescript
// File: backend/src/modules/document-numbering/document-numbering.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([
DocumentNumberCounter,
DocumentNumberFormat,
Organization,
CorrespondenceType,
Discipline,
]),
RedisModule,
],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService],
})
export class DocumentNumberingModule {}
```
---
## ✅ Testing & Verification
### 1. Concurrent Test
```typescript
describe('DocumentNumberingService - Concurrency', () => {
it('should generate 100 unique numbers concurrently', async () => {
const context = {
projectId: 1,
organizationId: 3,
typeId: 1,
disciplineId: 2,
year: 2025,
};
const promises = Array(100)
.fill(null)
.map(() => service.generateNextNumber(context));
const results = await Promise.all(promises);
// Check uniqueness
const unique = new Set(results);
expect(unique.size).toBe(100);
// Check format
results.forEach((num) => {
expect(num).toMatch(/^TEAM-RFA-STR-2025-\d{4}$/);
});
});
it('should handle Redis lock timeout', async () => {
// Mock Redis lock to always timeout
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
await expect(service.generateNextNumber(context)).rejects.toThrow();
});
it('should retry on version conflict', async () => {
// Simulate conflict on first attempt
let attempt = 0;
jest.spyOn(counterRepo, 'createQueryBuilder').mockImplementation(() => {
attempt++;
return {
update: () => ({
set: () => ({
where: () => ({
execute: async () => ({
affected: attempt === 1 ? 0 : 1, // Fail first, succeed second
}),
}),
}),
}),
} as any;
});
const result = await service.generateNextNumber(context);
expect(result).toBeDefined();
expect(attempt).toBe(2);
});
});
```
### 2. Load Test
```yaml
# artillery.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 30
arrivalRate: 20 # 20 req/sec
scenarios:
- name: 'Generate Document Numbers'
flow:
- post:
url: '/correspondences'
json:
title: 'Load Test {{ $randomString() }}'
project_id: 1
type_id: 1
discipline_id: 2
```
---
## 📚 Related Documents
- [ADR-002: Document Numbering Strategy](../05-decisions/ADR-002-document-numbering-strategy.md)
- [Backend Guidelines - Document Numbering](../03-implementation/backend-guidelines.md#document-numbering)
---
## 📦 Deliverables
- [ ] DocumentNumberingService
- [ ] DocumentNumberCounter Entity
- [ ] DocumentNumberFormat Entity
- [ ] Format Template Parser
- [ ] Redis Lock Integration
- [ ] Retry Logic with Backoff
- [ ] Unit Tests (90% coverage)
- [ ] Concurrent Tests
- [ ] Load Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ----------------------- | ------ | --------------------------------- |
| Redis lock failure | High | Retry + DB fallback |
| Version conflicts | Medium | Exponential backoff retry |
| Lock timeout | Medium | Increase TTL, optimize queries |
| Performance degradation | High | Redis caching, connection pooling |
---
## 📌 Notes
- Redis lock TTL: 3 seconds
- Max retries: 3
- Exponential backoff: 200ms → 400ms → 800ms
- Format template stored in database (configurable)
- Counters reset automatically per year

View File

@@ -0,0 +1,521 @@
# Task: Correspondence Module
**Status:** Not Started
**Priority:** P1 (High - Core Business Module)
**Estimated Effort:** 7-10 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Correspondence Module สำหรับจัดการเอกสารโต้ตอบด้วย Master-Revision Pattern พร้อม Workflow Integration
---
## 🎯 Objectives
- ✅ CRUD Operations (Correspondences + Revisions)
- ✅ Master-Revision Pattern Implementation
- ✅ Attachment Management
- ✅ Workflow Integration (Routing)
- ✅ Document Number Generation
- ✅ Search & Filter
---
## 📝 Acceptance Criteria
1. **Basic Operations:**
- ✅ Create correspondence (auto-generate number)
- ✅ Create revision
- ✅ Update correspondence/revision
- ✅ Soft delete correspondence
- ✅ Get correspondence with latest revision
- ✅ Get all revisions history
2. **Attachments:**
- ✅ Upload via two-phase storage
- ✅ Link attachments to revision
- ✅ Download attachments
- ✅ Delete attachments
3. **Workflow:**
- ✅ Submit correspondence → Create workflow instance
- ✅ Execute workflow transitions
- ✅ Track workflow status
4. **Search & Filter:**
- ✅ Search by title, number, project
- ✅ Filter by status, type, date range
- ✅ Pagination support
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/correspondence/entities/correspondence.entity.ts
@Entity('correspondences')
export class Correspondence extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
correspondence_number: string;
@Column({ length: 500 })
title: string;
@Column()
project_id: number;
@Column()
originator_organization_id: number;
@Column()
recipient_organization_id: number;
@Column()
correspondence_type_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ default: 'draft' })
status: string;
@Column()
created_by_user_id: number;
@DeleteDateColumn()
deleted_at: Date;
// Relationships
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'originator_organization_id' })
originatorOrganization: Organization;
@OneToMany(() => CorrespondenceRevision, (rev) => rev.correspondence)
revisions: CorrespondenceRevision[];
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by_user_id' })
createdBy: User;
}
```
```typescript
// File: backend/src/modules/correspondence/entities/correspondence-revision.entity.ts
@Entity('correspondence_revisions')
export class CorrespondenceRevision {
@PrimaryGeneratedColumn()
id: number;
@Column()
correspondence_id: number;
@Column({ default: 1 })
revision_number: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'json', nullable: true })
details: any; // Dynamic JSON field
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
// Relationships
@ManyToOne(() => Correspondence, (corr) => corr.revisions)
@JoinColumn({ name: 'correspondence_id' })
correspondence: Correspondence;
@ManyToMany(() => Attachment)
@JoinTable({
name: 'correspondence_attachments',
joinColumn: { name: 'correspondence_revision_id' },
inverseJoinColumn: { name: 'attachment_id' },
})
attachments: Attachment[];
}
```
### 2. Service
```typescript
// File: backend/src/modules/correspondence/correspondence.service.ts
@Injectable()
export class CorrespondenceService {
constructor(
@InjectRepository(Correspondence)
private corrRepo: Repository<Correspondence>,
@InjectRepository(CorrespondenceRevision)
private revisionRepo: Repository<CorrespondenceRevision>,
private fileStorage: FileStorageService,
private docNumbering: DocumentNumberingService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource
) {}
async create(
dto: CreateCorrespondenceDto,
userId: number
): Promise<Correspondence> {
return this.dataSource.transaction(async (manager) => {
// 1. Generate document number
const docNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.originator_organization_id,
typeId: dto.correspondence_type_id,
disciplineId: dto.discipline_id,
});
// 2. Create correspondence master
const correspondence = manager.create(Correspondence, {
correspondence_number: docNumber,
title: dto.title,
project_id: dto.project_id,
originator_organization_id: dto.originator_organization_id,
recipient_organization_id: dto.recipient_organization_id,
correspondence_type_id: dto.correspondence_type_id,
discipline_id: dto.discipline_id,
status: 'draft',
created_by_user_id: userId,
});
await manager.save(correspondence);
// 3. Create initial revision
const revision = manager.create(CorrespondenceRevision, {
correspondence_id: correspondence.id,
revision_number: 1,
description: dto.description,
details: dto.details,
created_by_user_id: userId,
});
await manager.save(revision);
// 4. Commit temp files (if any)
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
correspondence.id,
'correspondence',
manager
);
// Link attachments to revision
revision.attachments = attachments;
await manager.save(revision);
}
// 5. Create workflow instance
const workflowInstance = await this.workflowEngine.createInstance(
'CORRESPONDENCE_ROUTING',
'correspondence',
correspondence.id,
manager
);
return correspondence;
});
}
async createRevision(
correspondenceId: number,
dto: CreateRevisionDto,
userId: number
): Promise<CorrespondenceRevision> {
return this.dataSource.transaction(async (manager) => {
// Get latest revision number
const latestRevision = await manager.findOne(CorrespondenceRevision, {
where: { correspondence_id: correspondenceId },
order: { revision_number: 'DESC' },
});
const nextRevisionNumber = (latestRevision?.revision_number || 0) + 1;
// Create new revision
const revision = manager.create(CorrespondenceRevision, {
correspondence_id: correspondenceId,
revision_number: nextRevisionNumber,
description: dto.description,
details: dto.details,
created_by_user_id: userId,
});
await manager.save(revision);
// Commit temp files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
correspondenceId,
'correspondence',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
return revision;
});
}
async findAll(
query: SearchCorrespondenceDto
): Promise<PaginatedResult<Correspondence>> {
const queryBuilder = this.corrRepo
.createQueryBuilder('corr')
.leftJoinAndSelect('corr.project', 'project')
.leftJoinAndSelect('corr.originatorOrganization', 'org')
.leftJoinAndSelect('corr.revisions', 'revision')
.where('corr.deleted_at IS NULL');
// Apply filters
if (query.project_id) {
queryBuilder.andWhere('corr.project_id = :projectId', {
projectId: query.project_id,
});
}
if (query.status) {
queryBuilder.andWhere('corr.status = :status', { status: query.status });
}
if (query.search) {
queryBuilder.andWhere(
'(corr.title LIKE :search OR corr.correspondence_number LIKE :search)',
{ search: `%${query.search}%` }
);
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('corr.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: number): Promise<Correspondence> {
const correspondence = await this.corrRepo.findOne({
where: { id, deleted_at: IsNull() },
relations: [
'revisions',
'revisions.attachments',
'project',
'originatorOrganization',
],
order: { revisions: { revision_number: 'DESC' } },
});
if (!correspondence) {
throw new NotFoundException(`Correspondence #${id} not found`);
}
return correspondence;
}
async submitForRouting(id: number, userId: number): Promise<void> {
const correspondence = await this.findOne(id);
if (correspondence.status !== 'draft') {
throw new BadRequestException('Can only submit draft correspondences');
}
// Execute workflow transition
await this.workflowEngine.executeTransition(
correspondence.id,
'SUBMIT',
userId
);
// Update status
await this.corrRepo.update(id, { status: 'submitted' });
}
async softDelete(id: number, userId: number): Promise<void> {
const correspondence = await this.findOne(id);
if (correspondence.status !== 'draft') {
throw new BadRequestException('Can only delete draft correspondences');
}
await this.corrRepo.softDelete(id);
}
}
```
### 3. Controller
```typescript
// File: backend/src/modules/correspondence/correspondence.controller.ts
@Controller('correspondences')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Correspondences')
export class CorrespondenceController {
constructor(private service: CorrespondenceService) {}
@Post()
@RequirePermission('correspondence.create')
@UseInterceptors(IdempotencyInterceptor)
async create(
@Body() dto: CreateCorrespondenceDto,
@CurrentUser() user: User
): Promise<Correspondence> {
return this.service.create(dto, user.user_id);
}
@Post(':id/revisions')
@RequirePermission('correspondence.edit')
async createRevision(
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreateRevisionDto,
@CurrentUser() user: User
): Promise<CorrespondenceRevision> {
return this.service.createRevision(id, dto, user.user_id);
}
@Get()
@RequirePermission('correspondence.view')
async findAll(@Query() query: SearchCorrespondenceDto) {
return this.service.findAll(query);
}
@Get(':id')
@RequirePermission('correspondence.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Post(':id/submit')
@RequirePermission('correspondence.submit')
async submit(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
): Promise<void> {
return this.service.submitForRouting(id, user.user_id);
}
@Delete(':id')
@RequirePermission('correspondence.delete')
@HttpCode(204)
async delete(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
): Promise<void> {
return this.service.softDelete(id, user.user_id);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('CorrespondenceService', () => {
it('should create correspondence with document number', async () => {
const dto = {
title: 'Test Correspondence',
project_id: 1,
originator_organization_id: 3,
recipient_organization_id: 1,
correspondence_type_id: 1,
};
const result = await service.create(dto, 1);
expect(result.correspondence_number).toMatch(/^TEAM-RFA-\d{4}-\d{4}$/);
expect(result.revisions).toHaveLength(1);
});
});
```
### 2. Integration Tests
```bash
# Create correspondence
curl -X POST http://localhost:3000/correspondences \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"title": "Test Correspondence",
"project_id": 1,
"originator_organization_id": 3,
"recipient_organization_id": 1,
"correspondence_type_id": 1,
"temp_file_ids": ["temp-id-123"]
}'
```
---
## 📚 Related Documents
- [Data Model - Correspondences](../02-architecture/data-model.md#correspondences)
- [Functional Requirements - Correspondence](../01-requirements/03.2-correspondence.md)
---
## 📦 Deliverables
- [ ] Correspondence Entity
- [ ] CorrespondenceRevision Entity
- [ ] CorrespondenceService (CRUD + Workflow)
- [ ] CorrespondenceController
- [ ] DTOs (Create, Update, Search)
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] API Documentation (Swagger)
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------------- | -------- | ------------------------------ |
| Document number collision | Critical | Double-lock mechanism |
| File orphans | Medium | Two-phase storage |
| Workflow state mismatch | High | Transaction-safe state updates |
---
## 📌 Notes
- Use Master-Revision pattern (separate tables)
- Auto-generate document number on create
- Workflow integration required for submit
- Soft delete only drafts
- Pagination default: 20 items per page

View File

@@ -0,0 +1,540 @@
# Task: Workflow Engine Module
**Status:** Not Started
**Priority:** P0 (Critical - Core Infrastructure)
**Estimated Effort:** 10-14 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Unified Workflow Engine ที่ใช้ DSL-based configuration สำหรับจัดการ Workflow ของ Correspondences, RFAs, และ Circulations
---
## Objectives
- ✅ DSL Parser และ Validator
- ✅ State Machine Management
- ✅ Workflow Instance Lifecycle
- ✅ Transition Execution
- ✅ History Tracking
- ✅ Notification Integration
---
## 📝 Acceptance Criteria
1. **Definition Management:**
- ✅ Create/Update workflow from JSON DSL
- ✅ Validate DSL syntax และ Logic
- ✅ Version management
- ✅ Activate/Deactivate definitions
2. **Instance Management:**
- ✅ Create instance from definition
- ✅ Execute transitions
- ✅ Check guards (permissions, validations)
- ✅ Trigger effects (notifications, updates)
- ✅ Track history
3. **Integration:**
- ✅ Used by Correspondence module
- ✅ Used by RFA module
- ✅ Used by Circulation module
- ✅ Notification service integration
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/workflow-engine/entities/workflow-definition.entity.ts
@Entity('workflow_definitions')
export class WorkflowDefinition {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column()
version: number;
@Column({ length: 50 })
entity_type: string; // 'correspondence', 'rfa', 'circulation'
@Column({ type: 'json' })
definition: WorkflowDSL; // JSON DSL
@Column({ default: true })
is_active: boolean;
@CreateDateColumn()
created_at: Date;
@Index(['name', 'version'], { unique: true })
_nameVersionIndex: void;
}
```
```typescript
// File: backend/src/modules/workflow-engine/entities/workflow-instance.entity.ts
@Entity('workflow_instances')
export class WorkflowInstance {
@PrimaryGeneratedColumn()
id: number;
@Column()
definition_id: number;
@Column({ length: 50 })
entity_type: string;
@Column()
entity_id: number;
@Column({ length: 50 })
current_state: string;
@Column({ type: 'json', nullable: true })
context: any; // Runtime data
@CreateDateColumn()
started_at: Date;
@Column({ type: 'timestamp', nullable: true })
completed_at: Date;
@ManyToOne(() => WorkflowDefinition)
@JoinColumn({ name: 'definition_id' })
definition: WorkflowDefinition;
@OneToMany(() => WorkflowHistory, (history) => history.instance)
history: WorkflowHistory[];
}
```
```typescript
// File: backend/src/modules/workflow-engine/entities/workflow-history.entity.ts
@Entity('workflow_history')
export class WorkflowHistory {
@PrimaryGeneratedColumn()
id: number;
@Column()
instance_id: number;
@Column({ length: 50, nullable: true })
from_state: string;
@Column({ length: 50 })
to_state: string;
@Column({ length: 50 })
action: string;
@Column()
actor_id: number;
@Column({ type: 'json', nullable: true })
metadata: any;
@CreateDateColumn()
transitioned_at: Date;
@ManyToOne(() => WorkflowInstance, (instance) => instance.history)
@JoinColumn({ name: 'instance_id' })
instance: WorkflowInstance;
@ManyToOne(() => User)
@JoinColumn({ name: 'actor_id' })
actor: User;
}
```
### 2. DSL Types
```typescript
// File: backend/src/modules/workflow-engine/types/workflow-dsl.type.ts
export interface WorkflowDSL {
name: string;
version: number;
entity_type: string;
states: WorkflowState[];
transitions: WorkflowTransition[];
}
export interface WorkflowState {
name: string;
type: 'initial' | 'intermediate' | 'final';
allowed_transitions: string[];
}
export interface WorkflowTransition {
action: string;
from: string;
to: string;
guards?: Guard[];
effects?: Effect[];
}
export interface Guard {
type: 'permission' | 'validation' | 'condition';
permission?: string;
rules?: string[];
condition?: string;
}
export interface Effect {
type: 'notification' | 'update_entity' | 'create_log';
template?: string;
recipients?: string[];
field?: string;
value?: any;
}
```
### 3. DSL Parser
```typescript
// File: backend/src/modules/workflow-engine/services/dsl-parser.service.ts
@Injectable()
export class DslParserService {
parseDefinition(dsl: WorkflowDSL): ParsedWorkflow {
this.validateStructure(dsl);
this.validateStates(dsl);
this.validateTransitions(dsl);
return {
states: this.parseStates(dsl.states),
transitions: this.parseTransitions(dsl.transitions),
stateMap: this.buildStateMap(dsl.states),
};
}
private validateStructure(dsl: WorkflowDSL): void {
if (!dsl.name || !dsl.states || !dsl.transitions) {
throw new BadRequestException('Invalid DSL structure');
}
}
private validateStates(dsl: WorkflowDSL): void {
const initialStates = dsl.states.filter((s) => s.type === 'initial');
if (initialStates.length !== 1) {
throw new BadRequestException('Must have exactly one initial state');
}
const finalStates = dsl.states.filter((s) => s.type === 'final');
if (finalStates.length === 0) {
throw new BadRequestException('Must have at least one final state');
}
}
private validateTransitions(dsl: WorkflowDSL): void {
const stateNames = new Set(dsl.states.map((s) => s.name));
for (const transition of dsl.transitions) {
if (!stateNames.has(transition.from)) {
throw new BadRequestException(`Unknown state: ${transition.from}`);
}
if (!stateNames.has(transition.to)) {
throw new BadRequestException(`Unknown state: ${transition.to}`);
}
}
}
getInitialState(dsl: WorkflowDSL): string {
const initialState = dsl.states.find((s) => s.type === 'initial');
return initialState.name;
}
buildStateMap(states: WorkflowState[]): Map<string, WorkflowState> {
return new Map(states.map((s) => [s.name, s]));
}
}
```
### 4. Workflow Engine Service
```typescript
// File: backend/src/modules/workflow-engine/services/workflow-engine.service.ts
@Injectable()
export class WorkflowEngineService {
constructor(
@InjectRepository(WorkflowDefinition)
private defRepo: Repository<WorkflowDefinition>,
@InjectRepository(WorkflowInstance)
private instanceRepo: Repository<WorkflowInstance>,
@InjectRepository(WorkflowHistory)
private historyRepo: Repository<WorkflowHistory>,
private dslParser: DslParserService,
private guardExecutor: GuardExecutorService,
private effectExecutor: EffectExecutorService,
private dataSource: DataSource
) {}
async createInstance(
definitionName: string,
entityType: string,
entityId: number,
manager?: EntityManager
): Promise<WorkflowInstance> {
const repo = manager || this.instanceRepo;
//Get active definition
const definition = await this.defRepo.findOne({
where: { name: definitionName, entity_type: entityType, is_active: true },
order: { version: 'DESC' },
});
if (!definition) {
throw new NotFoundException(
`Workflow definition not found: ${definitionName}`
);
}
// Get initial state
const initialState = this.dslParser.getInitialState(definition.definition);
// Create instance
const instance = repo.create({
definition_id: definition.id,
entity_type: entityType,
entity_id: entityId,
current_state: initialState,
context: {},
});
return repo.save(instance);
}
async executeTransition(
instanceId: number,
action: string,
actorId: number
): Promise<void> {
return this.dataSource.transaction(async (manager) => {
// 1. Get instance
const instance = await manager.findOne(WorkflowInstance, {
where: { id: instanceId },
relations: ['definition'],
});
if (!instance) {
throw new NotFoundException(
`Workflow instance not found: ${instanceId}`
);
}
// 2. Find transition
const dsl = instance.definition.definition;
const transition = dsl.transitions.find(
(t) => t.action === action && t.from === instance.current_state
);
if (!transition) {
throw new BadRequestException(
`Invalid transition: ${action} from ${instance.current_state}`
);
}
// 3. Execute guards
await this.guardExecutor.checkGuards(transition.guards, {
actorId,
instance,
});
// 4. Update state
const fromState = instance.current_state;
instance.current_state = transition.to;
// Check if reached final state
const toStateConfig = dsl.states.find((s) => s.name === transition.to);
if (toStateConfig.type === 'final') {
instance.completed_at = new Date();
}
await manager.save(instance);
// 5. Record history
await manager.save(WorkflowHistory, {
instance_id: instanceId,
from_state: fromState,
to_state: transition.to,
action,
actor_id: actorId,
metadata: {},
});
// 6. Execute effects
await this.effectExecutor.executeEffects(transition.effects, {
instance,
actorId,
manager,
});
});
}
async getInstanceHistory(instanceId: number): Promise<WorkflowHistory[]> {
return this.historyRepo.find({
where: { instance_id: instanceId },
relations: ['actor'],
order: { transitioned_at: 'ASC' },
});
}
async getCurrentState(entityType: string, entityId: number): Promise<string> {
const instance = await this.instanceRepo.findOne({
where: { entity_type: entityType, entity_id: entityId },
order: { started_at: 'DESC' },
});
return instance?.current_state || null;
}
}
```
### 5. Guard Executor
```typescript
// File: backend/src/modules/workflow-engine/services/guard-executor.service.ts
@Injectable()
export class GuardExecutorService {
constructor(private abilityFactory: AbilityFactory) {}
async checkGuards(guards: Guard[], context: any): Promise<void> {
if (!guards || guards.length === 0) {
return;
}
for (const guard of guards) {
await this.checkGuard(guard, context);
}
}
private async checkGuard(guard: Guard, context: any): Promise<void> {
switch (guard.type) {
case 'permission':
await this.checkPermission(guard.permission, context);
break;
case 'validation':
await this.checkValidation(guard.rules, context);
break;
case 'condition':
await this.checkCondition(guard.condition, context);
break;
default:
throw new BadRequestException(`Unknown guard type: ${guard.type}`);
}
}
private async checkPermission(
permission: string,
context: any
): Promise<void> {
const ability = await this.abilityFactory.createForUser({
user_id: context.actorId,
});
const [action, subject] = permission.split('.');
if (!ability.can(action, subject)) {
throw new ForbiddenException(`Permission denied: ${permission}`);
}
}
private async checkValidation(rules: string[], context: any): Promise<void> {
// Implement validation rules
// e.g., "hasAttachment", "hasRecipient"
}
private async checkCondition(condition: string, context: any): Promise<void> {
// Evaluate condition expression
// e.g., "entity.status === 'draft'"
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('WorkflowEngineService', () => {
it('should create instance with initial state', async () => {
const instance = await service.createInstance(
'CORRESPONDENCE_ROUTING',
'correspondence',
1
);
expect(instance.current_state).toBe('DRAFT');
});
it('should execute valid transition', async () => {
await service.executeTransition(instance.id, 'SUBMIT', userId);
const updated = await instanceRepo.findOne(instance.id);
expect(updated.current_state).toBe('SUBMITTED');
});
it('should reject invalid transition', async () => {
await expect(
service.executeTransition(instance.id, 'INVALID_ACTION', userId)
).rejects.toThrow('Invalid transition');
});
});
```
---
## 📚 Related Documents
- [ADR-001: Unified Workflow Engine](../05-decisions/ADR-001-unified-workflow-engine.md)
- [Unified Workflow Requirements](../01-requirements/03.6-unified-workflow.md)
---
## 📦 Deliverables
- [ ] Workflow Entities (Definition, Instance, History)
- [ ] DSL Parser และ Validator
- [ ] WorkflowEngineService
- [ ] Guard Executor
- [ ] Effect Executor
- [ ] Example Workflow Definitions
- [ ] Unit Tests (90% coverage)
- [ ] Integration Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------ | -------- | --------------------------------------- |
| DSL parsing errors | High | Comprehensive validation |
| Guard failures | Medium | Clear error messages |
| State corruption | Critical | Transaction-safe updates |
| Performance issues | Medium | Optimize DSL parsing, cache definitions |
---
## 📌 Notes
- DSL structure validated on save
- Workflow definitions versioned
- Guard checks before state changes
- History tracked for audit trail
- Effects executed after state update

View File

@@ -0,0 +1,587 @@
# Task: RFA Module
**Status:** Not Started
**Priority:** P1 (High - Core Business Module)
**Estimated Effort:** 8-12 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004, TASK-BE-006
**Owner:** Backend Team
---
## 📋 Overview
สร้าง RFA (Request for Approval) Module สำหรับจัดการเอกสารขออนุมัติด้วย Master-Revision Pattern พร้อม Approval Workflow
---
## 🎯 Objectives
- ✅ CRUD Operations (RFAs + Revisions + Items)
- ✅ Master-Revision Pattern
- ✅ RFA Items Management
- ✅ Approval Workflow Integration
- ✅ Response/Approve Actions
- ✅ Status Tracking
---
## 📝 Acceptance Criteria
1. **Basic Operations:**
- ✅ Create RFA with auto-generated number
- ✅ Add/Update/Delete RFA items
- ✅ Create revision
- ✅ Get RFA with all items and attachments
2. **Approval Workflow:**
- ✅ Submit RFA → Start approval workflow
- ✅ Review RFA (Approve/Reject/Revise)
- ✅ Respond to RFA
- ✅ Track approval status
3. **RFA Items:**
- ✅ Add multiple items to RFA
- ✅ Link items to drawings (optional)
- ✅ Item-level approval tracking
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/rfa/entities/rfa.entity.ts
@Entity('rfas')
export class Rfa extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
rfa_number: string;
@Column({ length: 500 })
subject: string;
@Column()
project_id: number;
@Column()
contractor_organization_id: number;
@Column()
consultant_organization_id: number;
@Column()
rfa_type_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ default: 'draft' })
status: string;
@Column({ nullable: true })
approved_code_id: number; // Final approval result
@Column()
created_by_user_id: number;
@DeleteDateColumn()
deleted_at: Date;
// Relationships
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => RfaRevision, (rev) => rev.rfa)
revisions: RfaRevision[];
@OneToMany(() => RfaItem, (item) => item.rfa)
items: RfaItem[];
@ManyToOne(() => RfaApproveCode)
@JoinColumn({ name: 'approved_code_id' })
approvedCode: RfaApproveCode;
}
```
```typescript
// File: backend/src/modules/rfa/entities/rfa-revision.entity.ts
@Entity('rfa_revisions')
export class RfaRevision {
@PrimaryGeneratedColumn()
id: number;
@Column()
rfa_id: number;
@Column({ default: 1 })
revision_number: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'json', nullable: true })
details: any;
@Column({ type: 'date', nullable: true })
required_date: Date;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Rfa, (rfa) => rfa.revisions)
@JoinColumn({ name: 'rfa_id' })
rfa: Rfa;
@ManyToMany(() => Attachment)
@JoinTable({
name: 'rfa_attachments',
joinColumn: { name: 'rfa_revision_id' },
inverseJoinColumn: { name: 'attachment_id' },
})
attachments: Attachment[];
}
```
```typescript
// File: backend/src/modules/rfa/entities/rfa-item.entity.ts
@Entity('rfa_items')
export class RfaItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
rfa_id: number;
@Column({ length: 500 })
item_description: string;
@Column({ nullable: true })
drawing_id: number;
@Column({ nullable: true })
location: string;
@Column({ nullable: true })
quantity: number;
@Column({ length: 50, nullable: true })
unit: string;
@Column({ type: 'text', nullable: true })
remarks: string;
@ManyToOne(() => Rfa, (rfa) => rfa.items)
@JoinColumn({ name: 'rfa_id' })
rfa: Rfa;
@ManyToOne(() => ShopDrawing)
@JoinColumn({ name: 'drawing_id' })
drawing: ShopDrawing;
}
```
### 2. Service
```typescript
// File: backend/src/modules/rfa/rfa.service.ts
@Injectable()
export class RfaService {
constructor(
@InjectRepository(Rfa)
private rfaRepo: Repository<Rfa>,
@InjectRepository(RfaRevision)
private revisionRepo: Repository<RfaRevision>,
@InjectRepository(RfaItem)
private itemRepo: Repository<RfaItem>,
private fileStorage: FileStorageService,
private docNumbering: DocumentNumberingService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource
) {}
async create(dto: CreateRfaDto, userId: number): Promise<Rfa> {
return this.dataSource.transaction(async (manager) => {
// 1. Generate RFA number
const rfaNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.contractor_organization_id,
typeId: dto.rfa_type_id,
disciplineId: dto.discipline_id,
});
// 2. Create RFA master
const rfa = manager.create(Rfa, {
rfa_number: rfaNumber,
subject: dto.subject,
project_id: dto.project_id,
contractor_organization_id: dto.contractor_organization_id,
consultant_organization_id: dto.consultant_organization_id,
rfa_type_id: dto.rfa_type_id,
discipline_id: dto.discipline_id,
status: 'draft',
created_by_user_id: userId,
});
await manager.save(rfa);
// 3. Create initial revision
const revision = manager.create(RfaRevision, {
rfa_id: rfa.id,
revision_number: 1,
description: dto.description,
details: dto.details,
required_date: dto.required_date,
created_by_user_id: userId,
});
await manager.save(revision);
// 4. Create RFA items
if (dto.items?.length > 0) {
const items = dto.items.map((item) =>
manager.create(RfaItem, {
rfa_id: rfa.id,
...item,
})
);
await manager.save(items);
}
// 5. Commit temp files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
rfa.id,
'rfa',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
// 6. Create workflow instance
await this.workflowEngine.createInstance(
'RFA_APPROVAL',
'rfa',
rfa.id,
manager
);
return rfa;
});
}
async submitForApproval(id: number, userId: number): Promise<void> {
const rfa = await this.findOne(id);
if (rfa.status !== 'draft') {
throw new BadRequestException('Can only submit draft RFAs');
}
// Validate items exist
if (!rfa.items || rfa.items.length === 0) {
throw new BadRequestException('RFA must have at least one item');
}
// Execute workflow transition
await this.workflowEngine.executeTransition(rfa.id, 'SUBMIT', userId);
// Update status
await this.rfaRepo.update(id, { status: 'submitted' });
}
async reviewRfa(
id: number,
action: 'approve' | 'reject' | 'revise',
dto: ReviewRfaDto,
userId: number
): Promise<void> {
const rfa = await this.findOne(id);
if (rfa.status !== 'submitted' && rfa.status !== 'under_review') {
throw new BadRequestException('Invalid RFA status for review');
}
// Execute workflow transition
const workflowAction = action.toUpperCase();
await this.workflowEngine.executeTransition(rfa.id, workflowAction, userId);
// Update RFA status and approval code
const updates: any = {
status:
action === 'approve'
? 'approved'
: action === 'reject'
? 'rejected'
: 'revising',
};
if (action === 'approve' && dto.approve_code_id) {
updates.approved_code_id = dto.approve_code_id;
}
await this.rfaRepo.update(id, updates);
}
async respondToRfa(
id: number,
dto: RespondRfaDto,
userId: number
): Promise<void> {
return this.dataSource.transaction(async (manager) => {
const rfa = await this.findOne(id);
if (rfa.status !== 'approved' && rfa.status !== 'rejected') {
throw new BadRequestException('RFA must be reviewed first');
}
// Create response revision
const latestRevision = await manager.findOne(RfaRevision, {
where: { rfa_id: id },
order: { revision_number: 'DESC' },
});
const responseRevision = manager.create(RfaRevision, {
rfa_id: id,
revision_number: (latestRevision?.revision_number || 0) + 1,
description: dto.response_description,
details: dto.response_details,
created_by_user_id: userId,
});
await manager.save(responseRevision);
// Commit response files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
id,
'rfa',
manager
);
responseRevision.attachments = attachments;
await manager.save(responseRevision);
}
// Update status
await manager.update(Rfa, id, { status: 'responded' });
// Execute workflow
await this.workflowEngine.executeTransition(id, 'RESPOND', userId);
});
}
async findAll(query: SearchRfaDto): Promise<PaginatedResult<Rfa>> {
const queryBuilder = this.rfaRepo
.createQueryBuilder('rfa')
.leftJoinAndSelect('rfa.project', 'project')
.leftJoinAndSelect('rfa.items', 'items')
.leftJoinAndSelect('rfa.approvedCode', 'approvedCode')
.where('rfa.deleted_at IS NULL');
// Apply filters
if (query.project_id) {
queryBuilder.andWhere('rfa.project_id = :projectId', {
projectId: query.project_id,
});
}
if (query.status) {
queryBuilder.andWhere('rfa.status = :status', { status: query.status });
}
if (query.search) {
queryBuilder.andWhere(
'(rfa.subject LIKE :search OR rfa.rfa_number LIKE :search)',
{ search: `%${query.search}%` }
);
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('rfa.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
async findOne(id: number): Promise<Rfa> {
const rfa = await this.rfaRepo.findOne({
where: { id, deleted_at: IsNull() },
relations: [
'revisions',
'revisions.attachments',
'items',
'items.drawing',
'project',
'approvedCode',
],
order: { revisions: { revision_number: 'DESC' } },
});
if (!rfa) {
throw new NotFoundException(`RFA #${id} not found`);
}
return rfa;
}
}
```
### 3. Controller
```typescript
// File: backend/src/modules/rfa/rfa.controller.ts
@Controller('rfas')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('RFAs')
export class RfaController {
constructor(private service: RfaService) {}
@Post()
@RequirePermission('rfa.create')
@UseInterceptors(IdempotencyInterceptor)
async create(
@Body() dto: CreateRfaDto,
@CurrentUser() user: User
): Promise<Rfa> {
return this.service.create(dto, user.user_id);
}
@Post(':id/submit')
@RequirePermission('rfa.submit')
async submit(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
) {
return this.service.submitForApproval(id, user.user_id);
}
@Post(':id/review')
@RequirePermission('rfa.review')
async review(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ReviewRfaDto,
@CurrentUser() user: User
) {
return this.service.reviewRfa(id, dto.action, dto, user.user_id);
}
@Post(':id/respond')
@RequirePermission('rfa.respond')
async respond(
@Param('id', ParseIntPipe) id: number,
@Body() dto: RespondRfaDto,
@CurrentUser() user: User
) {
return this.service.respondToRfa(id, dto, user.user_id);
}
@Get()
@RequirePermission('rfa.view')
async findAll(@Query() query: SearchRfaDto) {
return this.service.findAll(query);
}
@Get(':id')
@RequirePermission('rfa.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('RfaService', () => {
it('should create RFA with items', async () => {
const dto = {
subject: 'Test RFA',
project_id: 1,
contractor_organization_id: 3,
consultant_organization_id: 1,
rfa_type_id: 1,
items: [
{ item_description: 'Item 1', quantity: 10, unit: 'pcs' },
{ item_description: 'Item 2', quantity: 5, unit: 'm' },
],
};
const result = await service.create(dto, 1);
expect(result.rfa_number).toMatch(/^TEAM-RFA-\d{4}-\d{4}$/);
expect(result.items).toHaveLength(2);
});
it('should execute approval workflow', async () => {
await service.submitForApproval(rfa.id, userId);
await service.reviewRfa(
rfa.id,
'approve',
{ approve_code_id: 1 },
reviewerId
);
const updated = await service.findOne(rfa.id);
expect(updated.status).toBe('approved');
expect(updated.approved_code_id).toBe(1);
});
});
```
---
## 📚 Related Documents
- [Data Model - RFAs](../02-architecture/data-model.md#rfas)
- [Functional Requirements - RFA](../01-requirements/03.3-rfa.md)
---
## 📦 Deliverables
- [ ] Rfa, RfaRevision, RfaItem Entities
- [ ] RfaService (CRUD + Approval Workflow)
- [ ] RfaController
- [ ] DTOs (Create, Review, Respond, Search)
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| -------------------------- | ------ | ------------------------------ |
| Complex approval workflow | High | Clear state machine definition |
| Item management complexity | Medium | Transaction-safe CRUD |
| Response/revision tracking | Medium | Clear revision numbering |
---
## 📌 Notes
- RFA Items required before submit
- Approval codes from master data table
- Support multi-level approval workflow
- Response creates new revision
- Link items to drawings (optional)

View File

@@ -0,0 +1,584 @@
# Task: Drawing Module (Shop & Contract Drawings)
**Status:** Not Started
**Priority:** P2 (Medium - Supporting Module)
**Estimated Effort:** 6-8 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Drawing Module สำหรับจัดการ Shop Drawings (แบบก่อสร้าง) และ Contract Drawings (แบบคู่สัญญา)
---
## 🎯 Objectives
- ✅ Contract Drawing Management
- ✅ Shop Drawing with Master-Revision Pattern
- ✅ Drawing Categories
- ✅ Drawing References/Links
- ✅ Version Control
- ✅ Search & Filter
---
## 📝 Acceptance Criteria
1. **Contract Drawings:**
- ✅ Upload contract drawings
- ✅ Categorize by discipline
- ✅ Link to project/contract
- ✅ Search by drawing number
2. **Shop Drawings:**
- ✅ Create shop drawing with auto-number
- ✅ Create revisions
- ✅ Link to contract drawings
- ✅ Track submission status
3. **Drawing Management:**
- ✅ Version tracking
- ✅ Drawing categories
- ✅ Cross-references
- ✅ Attachment management
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/drawing/entities/contract-drawing.entity.ts
@Entity('contract_drawings')
export class ContractDrawing {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
drawing_number: string;
@Column({ length: 500 })
drawing_title: string;
@Column()
contract_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ nullable: true })
category_id: number;
@Column({ type: 'date', nullable: true })
issue_date: Date;
@Column({ length: 50, nullable: true })
revision: string;
@Column({ nullable: true })
attachment_id: number; // PDF file
@CreateDateColumn()
created_at: Date;
@DeleteDateColumn()
deleted_at: Date;
@ManyToOne(() => Contract)
@JoinColumn({ name: 'contract_id' })
contract: Contract;
@ManyToOne(() => Discipline)
@JoinColumn({ name: 'discipline_id' })
discipline: Discipline;
@ManyToOne(() => Attachment)
@JoinColumn({ name: 'attachment_id' })
attachment: Attachment;
@Index(['contract_id', 'drawing_number'], { unique: true })
_contractDrawingIndex: void;
}
```
```typescript
// File: backend/src/modules/drawing/entities/shop-drawing.entity.ts
@Entity('shop_drawings')
export class ShopDrawing extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100, unique: true })
drawing_number: string;
@Column({ length: 500 })
drawing_title: string;
@Column()
project_id: number;
@Column()
contractor_organization_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ nullable: true })
category_id: number;
@Column({ default: 'draft' })
status: string;
@Column()
created_by_user_id: number;
@DeleteDateColumn()
deleted_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => ShopDrawingRevision, (rev) => rev.shopDrawing)
revisions: ShopDrawingRevision[];
@ManyToMany(() => ContractDrawing)
@JoinTable({
name: 'shop_drawing_references',
joinColumn: { name: 'shop_drawing_id' },
inverseJoinColumn: { name: 'contract_drawing_id' },
})
contractDrawingReferences: ContractDrawing[];
}
```
```typescript
// File: backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts
@Entity('shop_drawing_revisions')
export class ShopDrawingRevision {
@PrimaryGeneratedColumn()
id: number;
@Column()
shop_drawing_id: number;
@Column({ default: 1 })
revision_number: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'json', nullable: true })
details: any;
@Column({ type: 'date', nullable: true })
submission_date: Date;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => ShopDrawing, (sd) => sd.revisions)
@JoinColumn({ name: 'shop_drawing_id' })
shopDrawing: ShopDrawing;
@ManyToMany(() => Attachment)
@JoinTable({
name: 'shop_drawing_attachments',
joinColumn: { name: 'shop_drawing_revision_id' },
inverseJoinColumn: { name: 'attachment_id' },
})
attachments: Attachment[];
}
```
### 2. Service
```typescript
// File: backend/src/modules/drawing/drawing.service.ts
@Injectable()
export class DrawingService {
constructor(
@InjectRepository(ContractDrawing)
private contractDrawingRepo: Repository<ContractDrawing>,
@InjectRepository(ShopDrawing)
private shopDrawingRepo: Repository<ShopDrawing>,
@InjectRepository(ShopDrawingRevision)
private shopRevisionRepo: Repository<ShopDrawingRevision>,
private fileStorage: FileStorageService,
private docNumbering: DocumentNumberingService,
private dataSource: DataSource
) {}
// Contract Drawing Methods
async createContractDrawing(
dto: CreateContractDrawingDto,
userId: number
): Promise<ContractDrawing> {
return this.dataSource.transaction(async (manager) => {
// Commit drawing file
const attachments = await this.fileStorage.commitFiles(
[dto.temp_file_id],
null,
'contract_drawing',
manager
);
const contractDrawing = manager.create(ContractDrawing, {
drawing_number: dto.drawing_number,
drawing_title: dto.drawing_title,
contract_id: dto.contract_id,
discipline_id: dto.discipline_id,
category_id: dto.category_id,
issue_date: dto.issue_date,
revision: dto.revision || 'A',
attachment_id: attachments[0].id,
});
return manager.save(contractDrawing);
});
}
async findAllContractDrawings(
query: SearchDrawingDto
): Promise<PaginatedResult<ContractDrawing>> {
const queryBuilder = this.contractDrawingRepo
.createQueryBuilder('cd')
.leftJoinAndSelect('cd.contract', 'contract')
.leftJoinAndSelect('cd.discipline', 'discipline')
.leftJoinAndSelect('cd.attachment', 'attachment')
.where('cd.deleted_at IS NULL');
if (query.contract_id) {
queryBuilder.andWhere('cd.contract_id = :contractId', {
contractId: query.contract_id,
});
}
if (query.discipline_id) {
queryBuilder.andWhere('cd.discipline_id = :disciplineId', {
disciplineId: query.discipline_id,
});
}
if (query.search) {
queryBuilder.andWhere(
'(cd.drawing_number LIKE :search OR cd.drawing_title LIKE :search)',
{ search: `%${query.search}%` }
);
}
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('cd.drawing_number', 'ASC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
// Shop Drawing Methods
async createShopDrawing(
dto: CreateShopDrawingDto,
userId: number
): Promise<ShopDrawing> {
return this.dataSource.transaction(async (manager) => {
// Generate drawing number
const drawingNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.contractor_organization_id,
typeId: 999, // Shop Drawing type
disciplineId: dto.discipline_id,
});
// Create shop drawing master
const shopDrawing = manager.create(ShopDrawing, {
drawing_number: drawingNumber,
drawing_title: dto.drawing_title,
project_id: dto.project_id,
contractor_organization_id: dto.contractor_organization_id,
discipline_id: dto.discipline_id,
category_id: dto.category_id,
status: 'draft',
created_by_user_id: userId,
});
await manager.save(shopDrawing);
// Create initial revision
const revision = manager.create(ShopDrawingRevision, {
shop_drawing_id: shopDrawing.id,
revision_number: 1,
description: dto.description,
details: dto.details,
submission_date: dto.submission_date,
created_by_user_id: userId,
});
await manager.save(revision);
// Commit files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
shopDrawing.id,
'shop_drawing',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
// Link contract drawing references
if (dto.contract_drawing_ids?.length > 0) {
const contractDrawings = await manager.findByIds(
ContractDrawing,
dto.contract_drawing_ids
);
shopDrawing.contractDrawingReferences = contractDrawings;
await manager.save(shopDrawing);
}
return shopDrawing;
});
}
async createShopDrawingRevision(
shopDrawingId: number,
dto: CreateShopDrawingRevisionDto,
userId: number
): Promise<ShopDrawingRevision> {
return this.dataSource.transaction(async (manager) => {
const latestRevision = await manager.findOne(ShopDrawingRevision, {
where: { shop_drawing_id: shopDrawingId },
order: { revision_number: 'DESC' },
});
const nextRevisionNumber = (latestRevision?.revision_number || 0) + 1;
const revision = manager.create(ShopDrawingRevision, {
shop_drawing_id: shopDrawingId,
revision_number: nextRevisionNumber,
description: dto.description,
details: dto.details,
submission_date: dto.submission_date,
created_by_user_id: userId,
});
await manager.save(revision);
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
shopDrawingId,
'shop_drawing',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
return revision;
});
}
async findAllShopDrawings(
query: SearchDrawingDto
): Promise<PaginatedResult<ShopDrawing>> {
const queryBuilder = this.shopDrawingRepo
.createQueryBuilder('sd')
.leftJoinAndSelect('sd.project', 'project')
.leftJoinAndSelect('sd.revisions', 'revisions')
.leftJoinAndSelect('sd.contractDrawingReferences', 'refs')
.where('sd.deleted_at IS NULL');
if (query.project_id) {
queryBuilder.andWhere('sd.project_id = :projectId', {
projectId: query.project_id,
});
}
if (query.search) {
queryBuilder.andWhere(
'(sd.drawing_number LIKE :search OR sd.drawing_title LIKE :search)',
{ search: `%${query.search}%` }
);
}
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('sd.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
async findOneShopDrawing(id: number): Promise<ShopDrawing> {
const shopDrawing = await this.shopDrawingRepo.findOne({
where: { id, deleted_at: IsNull() },
relations: [
'revisions',
'revisions.attachments',
'contractDrawingReferences',
'project',
],
order: { revisions: { revision_number: 'DESC' } },
});
if (!shopDrawing) {
throw new NotFoundException(`Shop Drawing #${id} not found`);
}
return shopDrawing;
}
}
```
### 3. Controller
```typescript
// File: backend/src/modules/drawing/drawing.controller.ts
@Controller('drawings')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Drawings')
export class DrawingController {
constructor(private service: DrawingService) {}
// Contract Drawings
@Post('contract')
@RequirePermission('drawing.create')
async createContractDrawing(
@Body() dto: CreateContractDrawingDto,
@CurrentUser() user: User
) {
return this.service.createContractDrawing(dto, user.user_id);
}
@Get('contract')
@RequirePermission('drawing.view')
async findAllContractDrawings(@Query() query: SearchDrawingDto) {
return this.service.findAllContractDrawings(query);
}
// Shop Drawings
@Post('shop')
@RequirePermission('drawing.create')
@UseInterceptors(IdempotencyInterceptor)
async createShopDrawing(
@Body() dto: CreateShopDrawingDto,
@CurrentUser() user: User
) {
return this.service.createShopDrawing(dto, user.user_id);
}
@Post('shop/:id/revisions')
@RequirePermission('drawing.edit')
async createShopDrawingRevision(
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreateShopDrawingRevisionDto,
@CurrentUser() user: User
) {
return this.service.createShopDrawingRevision(id, dto, user.user_id);
}
@Get('shop')
@RequirePermission('drawing.view')
async findAllShopDrawings(@Query() query: SearchDrawingDto) {
return this.service.findAllShopDrawings(query);
}
@Get('shop/:id')
@RequirePermission('drawing.view')
async findOneShopDrawing(@Param('id', ParseIntPipe) id: number) {
return this.service.findOneShopDrawing(id);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('DrawingService', () => {
it('should create contract drawing with PDF', async () => {
const dto = {
drawing_number: 'A-001',
drawing_title: 'Floor Plan',
contract_id: 1,
temp_file_id: 'temp-pdf-id',
};
const result = await service.createContractDrawing(dto, 1);
expect(result.attachment_id).toBeDefined();
});
it('should create shop drawing with auto number', async () => {
const dto = {
drawing_title: 'Shop Drawing Test',
project_id: 1,
contractor_organization_id: 3,
contract_drawing_ids: [1, 2],
};
const result = await service.createShopDrawing(dto, 1);
expect(result.drawing_number).toMatch(/^TEAM-SD-\d{4}-\d{4}$/);
expect(result.contractDrawingReferences).toHaveLength(2);
});
});
```
---
## 📚 Related Documents
- [Data Model - Drawings](../02-architecture/data-model.md#drawings)
- [Functional Requirements - Drawings](../01-requirements/03.4-contract-drawing.md)
---
## 📦 Deliverables
- [ ] ContractDrawing Entity
- [ ] ShopDrawing & ShopDrawingRevision Entities
- [ ] DrawingService (Both types)
- [ ] DrawingController
- [ ] DTOs
- [ ] Unit Tests (80% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| -------------------------- | ------ | --------------------------------- |
| Large drawing files | Medium | File size validation, compression |
| Drawing reference tracking | Medium | Junction table management |
| Version confusion | Low | Clear revision numbering |
---
## 📌 Notes
- Contract drawings: PDF uploads only
- Shop drawings: Auto-numbered with revisions
- Cross-references tracked in junction table
- Categories and disciplines from master data

View File

@@ -0,0 +1,578 @@
# Task: Circulation & Transmittal Modules
**Status:** Not Started
**Priority:** P2 (Medium)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-006
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Circulation Module (ใบเวียนภายใน) และ Transmittal Module (เอกสารนำส่ง) สำหรับจัดการการส่งเอกสารภายในและภายนอก
---
## 🎯 Objectives
- ✅ Circulation Sheet Management
- ✅ Transmittal Management
- ✅ Assignee Tracking
- ✅ Workflow Integration
- ✅ Document Linking
---
## 📝 Acceptance Criteria
1. **Circulation:**
- ✅ Create circulation sheet
- ✅ Add assignees (multiple users)
- ✅ Link documents (correspondences, RFAs)
- ✅ Track completion status
2. **Transmittal:**
- ✅ Create transmittal
- ✅ Add documents
- ✅ Generate transmittal number
- ✅ Print/Export transmittal letter
---
## 🛠️ Implementation Steps
### 1. Circulation Entities
```typescript
// File: backend/src/modules/circulation/entities/circulation.entity.ts
@Entity('circulations')
export class Circulation {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
circulation_number: string;
@Column({ length: 500 })
subject: string;
@Column()
project_id: number;
@Column()
organization_id: number;
@Column({ default: 'active' })
status: string;
@Column({ type: 'date', nullable: true })
due_date: Date;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => CirculationAssignee, (assignee) => assignee.circulation)
assignees: CirculationAssignee[];
@ManyToMany(() => Correspondence)
@JoinTable({ name: 'circulation_correspondences' })
correspondences: Correspondence[];
}
```
```typescript
// File: backend/src/modules/circulation/entities/circulation-assignee.entity.ts
@Entity('circulation_assignees')
export class CirculationAssignee {
@PrimaryGeneratedColumn()
id: number;
@Column()
circulation_id: number;
@Column()
user_id: number;
@Column({ default: 'pending' })
status: string;
@Column({ type: 'text', nullable: true })
remarks: string;
@Column({ type: 'timestamp', nullable: true })
completed_at: Date;
@ManyToOne(() => Circulation, (circ) => circ.assignees)
@JoinColumn({ name: 'circulation_id' })
circulation: Circulation;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}
```
### 2. Transmittal Entities
```typescript
// File: backend/src/modules/transmittal/entities/transmittal.entity.ts
@Entity('transmittals')
export class Transmittal {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
transmittal_number: string;
@Column({ length: 500 })
attention_to: string;
@Column()
project_id: number;
@Column()
from_organization_id: number;
@Column()
to_organization_id: number;
@Column({ type: 'date' })
transmittal_date: Date;
@Column({ type: 'text', nullable: true })
remarks: string;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => TransmittalItem, (item) => item.transmittal)
items: TransmittalItem[];
}
```
```typescript
// File: backend/src/modules/transmittal/entities/transmittal-item.entity.ts
@Entity('transmittal_items')
export class TransmittalItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
transmittal_id: number;
@Column({ length: 50 })
document_type: string; // 'correspondence', 'rfa', 'drawing'
@Column()
document_id: number;
@Column({ length: 100 })
document_number: string;
@Column({ length: 500, nullable: true })
document_title: string;
@Column({ default: 1 })
number_of_copies: number;
@ManyToOne(() => Transmittal, (trans) => trans.items)
@JoinColumn({ name: 'transmittal_id' })
transmittal: Transmittal;
}
```
### 3. Services
```typescript
// File: backend/src/modules/circulation/circulation.service.ts
@Injectable()
export class CirculationService {
constructor(
@InjectRepository(Circulation)
private circulationRepo: Repository<Circulation>,
@InjectRepository(CirculationAssignee)
private assigneeRepo: Repository<CirculationAssignee>,
private docNumbering: DocumentNumberingService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource
) {}
async create(
dto: CreateCirculationDto,
userId: number
): Promise<Circulation> {
return this.dataSource.transaction(async (manager) => {
// Generate circulation number
const circulationNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.organization_id,
typeId: 900, // Circulation type
});
// Create circulation
const circulation = manager.create(Circulation, {
circulation_number: circulationNumber,
subject: dto.subject,
project_id: dto.project_id,
organization_id: dto.organization_id,
due_date: dto.due_date,
status: 'active',
created_by_user_id: userId,
});
await manager.save(circulation);
// Add assignees
if (dto.assignee_user_ids?.length > 0) {
const assignees = dto.assignee_user_ids.map((userId) =>
manager.create(CirculationAssignee, {
circulation_id: circulation.id,
user_id: userId,
status: 'pending',
})
);
await manager.save(assignees);
}
// Link correspondences
if (dto.correspondence_ids?.length > 0) {
const correspondences = await manager.findByIds(
Correspondence,
dto.correspondence_ids
);
circulation.correspondences = correspondences;
await manager.save(circulation);
}
// Create workflow instance
await this.workflowEngine.createInstance(
'CIRCULATION_INTERNAL',
'circulation',
circulation.id,
manager
);
return circulation;
});
}
async completeAssignment(
circulationId: number,
assigneeId: number,
dto: CompleteAssignmentDto,
userId: number
): Promise<void> {
const assignee = await this.assigneeRepo.findOne({
where: { id: assigneeId, circulation_id: circulationId, user_id: userId },
});
if (!assignee) {
throw new NotFoundException('Assignment not found');
}
await this.assigneeRepo.update(assigneeId, {
status: 'completed',
remarks: dto.remarks,
completed_at: new Date(),
});
// Check if all assignees completed
const allAssignees = await this.assigneeRepo.find({
where: { circulation_id: circulationId },
});
const allCompleted = allAssignees.every((a) => a.status === 'completed');
if (allCompleted) {
await this.circulationRepo.update(circulationId, { status: 'completed' });
await this.workflowEngine.executeTransition(
circulationId,
'COMPLETE',
userId
);
}
}
}
```
```typescript
// File: backend/src/modules/transmittal/transmittal.service.ts
@Injectable()
export class TransmittalService {
constructor(
@InjectRepository(Transmittal)
private transmittalRepo: Repository<Transmittal>,
@InjectRepository(TransmittalItem)
private itemRepo: Repository<TransmittalItem>,
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@InjectRepository(Rfa)
private rfaRepo: Repository<Rfa>,
private docNumbering: DocumentNumberingService,
private dataSource: DataSource
) {}
async create(
dto: CreateTransmittalDto,
userId: number
): Promise<Transmittal> {
return this.dataSource.transaction(async (manager) => {
// Generate transmittal number
const transmittalNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.from_organization_id,
typeId: 901, // Transmittal type
});
// Create transmittal
const transmittal = manager.create(Transmittal, {
transmittal_number: transmittalNumber,
attention_to: dto.attention_to,
project_id: dto.project_id,
from_organization_id: dto.from_organization_id,
to_organization_id: dto.to_organization_id,
transmittal_date: dto.transmittal_date || new Date(),
remarks: dto.remarks,
created_by_user_id: userId,
});
await manager.save(transmittal);
// Add items
if (dto.items?.length > 0) {
for (const itemDto of dto.items) {
// Fetch document details
const docDetails = await this.getDocumentDetails(
itemDto.document_type,
itemDto.document_id,
manager
);
const item = manager.create(TransmittalItem, {
transmittal_id: transmittal.id,
document_type: itemDto.document_type,
document_id: itemDto.document_id,
document_number: docDetails.number,
document_title: docDetails.title,
number_of_copies: itemDto.number_of_copies || 1,
});
await manager.save(item);
}
}
return transmittal;
});
}
private async getDocumentDetails(
type: string,
id: number,
manager: EntityManager
): Promise<{ number: string; title: string }> {
switch (type) {
case 'correspondence':
const corr = await manager.findOne(Correspondence, { where: { id } });
return { number: corr.correspondence_number, title: corr.title };
case 'rfa':
const rfa = await manager.findOne(Rfa, { where: { id } });
return { number: rfa.rfa_number, title: rfa.subject };
default:
throw new BadRequestException(`Unknown document type: ${type}`);
}
}
async findOne(id: number): Promise<Transmittal> {
const transmittal = await this.transmittalRepo.findOne({
where: { id },
relations: ['items', 'project'],
});
if (!transmittal) {
throw new NotFoundException(`Transmittal #${id} not found`);
}
return transmittal;
}
async generatePDF(id: number): Promise<Buffer> {
const transmittal = await this.findOne(id);
// Generate PDF using template
// Implementation with library like pdfmake or puppeteer
return Buffer.from('PDF content');
}
}
```
### 4. Controllers
```typescript
// File: backend/src/modules/circulation/circulation.controller.ts
@Controller('circulations')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class CirculationController {
constructor(private service: CirculationService) {}
@Post()
@RequirePermission('circulation.create')
async create(@Body() dto: CreateCirculationDto, @CurrentUser() user: User) {
return this.service.create(dto, user.user_id);
}
@Post(':circulationId/assignees/:assigneeId/complete')
@RequirePermission('circulation.complete')
async completeAssignment(
@Param('circulationId', ParseIntPipe) circulationId: number,
@Param('assigneeId', ParseIntPipe) assigneeId: number,
@Body() dto: CompleteAssignmentDto,
@CurrentUser() user: User
) {
return this.service.completeAssignment(
circulationId,
assigneeId,
dto,
user.user_id
);
}
}
```
```typescript
// File: backend/src/modules/transmittal/transmittal.controller.ts
@Controller('transmittals')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class TransmittalController {
constructor(private service: TransmittalService) {}
@Post()
@RequirePermission('transmittal.create')
@UseInterceptors(IdempotencyInterceptor)
async create(@Body() dto: CreateTransmittalDto, @CurrentUser() user: User) {
return this.service.create(dto, user.user_id);
}
@Get(':id')
@RequirePermission('transmittal.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Get(':id/pdf')
@RequirePermission('transmittal.view')
async downloadPDF(
@Param('id', ParseIntPipe) id: number,
@Res() res: Response
) {
const pdf = await this.service.generatePDF(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename=transmittal-${id}.pdf`
);
res.send(pdf);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('CirculationService', () => {
it('should create circulation with assignees', async () => {
const dto = {
subject: 'Review Documents',
project_id: 1,
organization_id: 3,
assignee_user_ids: [1, 2, 3],
correspondence_ids: [10, 11],
};
const result = await service.create(dto, 1);
expect(result.assignees).toHaveLength(3);
expect(result.correspondences).toHaveLength(2);
});
});
describe('TransmittalService', () => {
it('should create transmittal with document items', async () => {
const dto = {
attention_to: 'Project Manager',
project_id: 1,
from_organization_id: 3,
to_organization_id: 1,
items: [
{ document_type: 'correspondence', document_id: 10 },
{ document_type: 'rfa', document_id: 5 },
],
};
const result = await service.create(dto, 1);
expect(result.items).toHaveLength(2);
});
});
```
---
## 📚 Related Documents
- [Functional Requirements - Circulation](../01-requirements/03.8-circulation-sheet.md)
- [Functional Requirements - Transmittal](../01-requirements/03.7-transmittals.md)
---
## 📦 Deliverables
- [ ] Circulation & CirculationAssignee Entities
- [ ] Transmittal & TransmittalItem Entities
- [ ] Services (Both modules)
- [ ] Controllers
- [ ] DTOs
- [ ] PDF Generation (Transmittal)
- [ ] Unit Tests (80% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------------- | ------ | ---------------------------- |
| PDF generation complexity | Medium | Use proven library (pdfmake) |
| Multi-assignee tracking | Medium | Clear status management |
| Document linking | Low | Foreign key validation |
---
## 📌 Notes
- Circulation tracks multiple assignees
- All assignees must complete before circulation closes
- Transmittal can include multiple document types
- PDF template for transmittal letter
- Auto-numbering for both modules

View File

@@ -0,0 +1,493 @@
# Task: Search & Elasticsearch Integration
**Status:** Not Started
**Priority:** P2 (Medium - Performance Enhancement)
**Estimated Effort:** 4-6 days
**Dependencies:** TASK-BE-001, TASK-BE-005, TASK-BE-007
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Search Module ที่ integrate กับ Elasticsearch สำหรับ Full-text Search และ Advanced Filtering
---
## 🎯 Objectives
- ✅ Elasticsearch Integration
- ✅ Full-text Search (Correspondences, RFAs, Drawings)
- ✅ Advanced Filters
- ✅ Search Result Aggregations
- ✅ Auto-indexing
---
## 📝 Acceptance Criteria
1. **Search Capabilities:**
- ✅ Search across multiple document types
- ✅ Full-text search in title, description
- ✅ Filter by project, status, date range
- ✅ Sort results by relevance/date
2. **Indexing:**
- ✅ Auto-index on document create/update
- ✅ Async indexing (via queue)
- ✅ Bulk re-indexing command
3. **Performance:**
- ✅ Search results < 500ms
- ✅ Pagination support
- ✅ Highlight search terms
---
## 🛠️ Implementation Steps
### 1. Elasticsearch Module Setup
```typescript
// File: backend/src/modules/search/search.module.ts
import { ElasticsearchModule } from '@nestjs/elasticsearch';
@Module({
imports: [
ElasticsearchModule.register({
node: process.env.ELASTICSEARCH_URL || 'http://localhost:9200',
}),
],
providers: [SearchService, SearchIndexer],
exports: [SearchService],
})
export class SearchModule {}
```
### 2. Index Mapping
```typescript
// File: backend/src/modules/search/mappings/correspondence.mapping.ts
export const correspondenceMapping = {
properties: {
id: { type: 'integer' },
correspondence_number: { type: 'keyword' },
title: {
type: 'text',
analyzer: 'standard',
fields: {
keyword: { type: 'keyword' },
},
},
description: {
type: 'text',
analyzer: 'standard',
},
project_id: { type: 'integer' },
project_name: { type: 'keyword' },
status: { type: 'keyword' },
created_at: { type: 'date' },
created_by_username: { type: 'keyword' },
organization_name: { type: 'keyword' },
type_name: { type: 'keyword' },
discipline_name: { type: 'keyword' },
},
};
```
### 3. Search Service
```typescript
// File: backend/src/modules/search/search.service.ts
import { Injectable } from '@nestjs/common';
import { ElasticsearchService } from '@nestjs/elasticsearch';
@Injectable()
export class SearchService {
private readonly INDEX_NAME = 'lcbp3-documents';
constructor(private elasticsearch: ElasticsearchService) {}
async onModuleInit() {
// Create index if not exists
const indexExists = await this.elasticsearch.indices.exists({
index: this.INDEX_NAME,
});
if (!indexExists) {
await this.createIndex();
}
}
private async createIndex(): Promise<void> {
await this.elasticsearch.indices.create({
index: this.INDEX_NAME,
body: {
mappings: {
properties: {
document_type: { type: 'keyword' },
...correspondenceMapping.properties,
...rfaMapping.properties,
},
},
},
});
}
async search(query: SearchQueryDto): Promise<SearchResult> {
const must: any[] = [];
const filter: any[] = [];
// Full-text search
if (query.search) {
must.push({
multi_match: {
query: query.search,
fields: ['title^2', 'description', 'correspondence_number'],
fuzziness: 'AUTO',
},
});
}
// Filters
if (query.document_type) {
filter.push({ term: { document_type: query.document_type } });
}
if (query.project_id) {
filter.push({ term: { project_id: query.project_id } });
}
if (query.status) {
filter.push({ term: { status: query.status } });
}
if (query.date_from || query.date_to) {
const range: any = {};
if (query.date_from) range.gte = query.date_from;
if (query.date_to) range.lte = query.date_to;
filter.push({ range: { created_at: range } });
}
// Execute search
const page = query.page || 1;
const limit = query.limit || 20;
const from = (page - 1) * limit;
const result = await this.elasticsearch.search({
index: this.INDEX_NAME,
body: {
from,
size: limit,
query: {
bool: {
must,
filter,
},
},
sort: query.sort_by
? [{ [query.sort_by]: { order: query.sort_order || 'desc' } }]
: [{ _score: 'desc' }, { created_at: 'desc' }],
highlight: {
fields: {
title: {},
description: {},
},
},
aggs: {
document_types: {
terms: { field: 'document_type' },
},
statuses: {
terms: { field: 'status' },
},
projects: {
terms: { field: 'project_id' },
},
},
},
});
return {
items: result.hits.hits.map((hit) => ({
...hit._source,
_score: hit._score,
_highlights: hit.highlight,
})),
total: result.hits.total.value,
page,
limit,
totalPages: Math.ceil(result.hits.total.value / limit),
aggregations: result.aggregations,
};
}
async indexDocument(
documentType: string,
documentId: number,
data: any
): Promise<void> {
await this.elasticsearch.index({
index: this.INDEX_NAME,
id: `${documentType}-${documentId}`,
body: {
document_type: documentType,
...data,
},
});
}
async updateDocument(
documentType: string,
documentId: number,
data: any
): Promise<void> {
await this.elasticsearch.update({
index: this.INDEX_NAME,
id: `${documentType}-${documentId}`,
body: {
doc: data,
},
});
}
async deleteDocument(
documentType: string,
documentId: number
): Promise<void> {
await this.elasticsearch.delete({
index: this.INDEX_NAME,
id: `${documentType}-${documentId}`,
});
}
}
```
### 4. Search Indexer (Queue Worker)
```typescript
// File: backend/src/modules/search/search-indexer.service.ts
import { Processor, Process } from '@nestjs/bullmq';
import { Job } from 'bullmq';
@Processor('search-indexing')
export class SearchIndexer {
constructor(
private searchService: SearchService,
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@InjectRepository(Rfa)
private rfaRepo: Repository<Rfa>
) {}
@Process('index-correspondence')
async indexCorrespondence(job: Job<{ id: number }>) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id: job.data.id },
relations: ['project', 'originatorOrganization', 'revisions'],
});
if (!correspondence) {
return;
}
const latestRevision = correspondence.revisions[0];
await this.searchService.indexDocument(
'correspondence',
correspondence.id,
{
id: correspondence.id,
correspondence_number: correspondence.correspondence_number,
title: correspondence.title,
description: latestRevision?.description,
project_id: correspondence.project_id,
project_name: correspondence.project.project_name,
status: correspondence.status,
created_at: correspondence.created_at,
organization_name:
correspondence.originatorOrganization.organization_name,
}
);
}
@Process('index-rfa')
async indexRfa(job: Job<{ id: number }>) {
const rfa = await this.rfaRepo.findOne({
where: { id: job.data.id },
relations: ['project', 'revisions'],
});
if (!rfa) {
return;
}
const latestRevision = rfa.revisions[0];
await this.searchService.indexDocument('rfa', rfa.id, {
id: rfa.id,
rfa_number: rfa.rfa_number,
title: rfa.subject,
description: latestRevision?.description,
project_id: rfa.project_id,
project_name: rfa.project.project_name,
status: rfa.status,
created_at: rfa.created_at,
});
}
@Process('bulk-reindex')
async bulkReindex(job: Job) {
// Re-index all correspondences
const correspondences = await this.correspondenceRepo.find({
relations: ['project', 'originatorOrganization', 'revisions'],
});
for (const corr of correspondences) {
await this.indexCorrespondence({ data: { id: corr.id } } as Job);
}
// Re-index all RFAs
const rfas = await this.rfaRepo.find({
relations: ['project', 'revisions'],
});
for (const rfa of rfas) {
await this.indexRfa({ data: { id: rfa.id } } as Job);
}
}
}
```
### 5. Integration with Service
```typescript
// File: backend/src/modules/correspondence/correspondence.service.ts (updated)
@Injectable()
export class CorrespondenceService {
constructor(
// ... existing dependencies
private searchQueue: Queue
) {}
async create(
dto: CreateCorrespondenceDto,
userId: number
): Promise<Correspondence> {
const correspondence = await this.dataSource.transaction(/* ... */);
// Queue for indexing (async)
await this.searchQueue.add('index-correspondence', {
id: correspondence.id,
});
return correspondence;
}
async update(id: number, dto: UpdateCorrespondenceDto): Promise<void> {
await this.corrRepo.update(id, dto);
// Re-index
await this.searchQueue.add('index-correspondence', { id });
}
}
```
### 6. Search Controller
```typescript
// File: backend/src/modules/search/search.controller.ts
@Controller('search')
@UseGuards(JwtAuthGuard)
export class SearchController {
constructor(private searchService: SearchService) {}
@Get()
async search(@Query() query: SearchQueryDto) {
return this.searchService.search(query);
}
@Post('reindex')
@RequirePermission('admin.manage')
async reindex() {
await this.searchQueue.add('bulk-reindex', {});
return { message: 'Re-indexing started' };
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('SearchService', () => {
it('should search with full-text query', async () => {
const result = await service.search({
search: 'foundation',
page: 1,
limit: 20,
});
expect(result.items).toBeDefined();
expect(result.total).toBeGreaterThan(0);
});
it('should filter by project and status', async () => {
const result = await service.search({
project_id: 1,
status: 'submitted',
});
result.items.forEach((item) => {
expect(item.project_id).toBe(1);
expect(item.status).toBe('submitted');
});
});
});
```
---
## 📚 Related Documents
- [System Architecture - Search](../02-architecture/system-architecture.md#elasticsearch)
- [ADR-005: Technology Stack](../05-decisions/ADR-005-technology-stack.md)
---
## 📦 Deliverables
- [ ] SearchService with Elasticsearch
- [ ] Search Indexer (Queue Worker)
- [ ] Index Mappings
- [ ] Queue Integration
- [ ] Search Controller
- [ ] Bulk Re-indexing Command
- [ ] Unit Tests (75% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------ | ------ | --------------------- |
| Elasticsearch down | Medium | Fallback to DB search |
| Index out of sync | Medium | Regular re-indexing |
| Large result sets | Low | Pagination + limits |
---
## 📌 Notes
- Async indexing via BullMQ
- Index correspondence, RFA, drawings
- Support Thai language search
- Highlight matching terms
- Aggregations for faceted search
- Re-index command for admin

View File

@@ -0,0 +1,524 @@
# Task: Notification & Audit Log Services
**Status:** Not Started
**Priority:** P3 (Low - Supporting Services)
**Estimated Effort:** 3-5 days
**Dependencies:** TASK-BE-001, TASK-BE-002
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Notification Service สำหรับส่งการแจ้งเตือน และ Audit Log Service สำหรับบันทึกประวัติการใช้งานระบบ
---
## 🎯 Objectives
- ✅ Email Notification
- ✅ LINE Notify Integration
- ✅ In-App Notifications
- ✅ Audit Log Recording
- ✅ Audit Log Query & Export
---
## 📝 Acceptance Criteria
1. **Notifications:**
- ✅ Send email via queue
- ✅ Send LINE Notify
- ✅ Store in-app notifications
- ✅ Mark notifications as read
- ✅ Notification templates
2. **Audit Logs:**
- ✅ Auto-log CRUD operations
- ✅ Log workflow transitions
- ✅ Query audit logs by user/entity
- ✅ Export to CSV
---
## 🛠️ Implementation Steps
### 1. Notification Entity
```typescript
// File: backend/src/modules/notification/entities/notification.entity.ts
@Entity('notifications')
export class Notification {
@PrimaryGeneratedColumn()
id: number;
@Column()
user_id: number;
@Column({ length: 100 })
notification_type: string;
@Column({ length: 500 })
title: string;
@Column({ type: 'text' })
message: string;
@Column({ length: 255, nullable: true })
link: string;
@Column({ default: false })
is_read: boolean;
@Column({ type: 'timestamp', nullable: true })
read_at: Date;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}
```
### 2. Notification Service
```typescript
// File: backend/src/modules/notification/notification.service.ts
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
@Injectable()
export class NotificationService {
constructor(
@InjectRepository(Notification)
private notificationRepo: Repository<Notification>,
@InjectQueue('email') private emailQueue: Queue,
@InjectQueue('line-notify') private lineQueue: Queue
) {}
async createNotification(dto: CreateNotificationDto): Promise<Notification> {
const notification = this.notificationRepo.create({
user_id: dto.user_id,
notification_type: dto.type,
title: dto.title,
message: dto.message,
link: dto.link,
});
return this.notificationRepo.save(notification);
}
async sendEmail(dto: SendEmailDto): Promise<void> {
await this.emailQueue.add('send-email', {
to: dto.to,
subject: dto.subject,
template: dto.template,
context: dto.context,
});
}
async sendLineNotify(dto: SendLineNotifyDto): Promise<void> {
await this.lineQueue.add('send-line', {
token: dto.token,
message: dto.message,
});
}
async notifyWorkflowTransition(
workflowId: number,
action: string,
actorId: number
): Promise<void> {
// Get relevant users to notify
const users = await this.getRelevantUsers(workflowId);
for (const user of users) {
// Create in-app notification
await this.createNotification({
user_id: user.user_id,
type: 'workflow_transition',
title: `${action} completed`,
message: `Workflow ${workflowId} has been ${action}`,
link: `/workflows/${workflowId}`,
});
// Send email
if (user.email_notifications_enabled) {
await this.sendEmail({
to: user.email,
subject: `Workflow Update`,
template: 'workflow-transition',
context: { action, workflowId },
});
}
// Send LINE
if (user.line_notify_token) {
await this.sendLineNotify({
token: user.line_notify_token,
message: `Workflow ${workflowId}: ${action}`,
});
}
}
}
async getUserNotifications(
userId: number,
unreadOnly: boolean = false
): Promise<Notification[]> {
const query: any = { user_id: userId };
if (unreadOnly) {
query.is_read = false;
}
return this.notificationRepo.find({
where: query,
order: { created_at: 'DESC' },
take: 50,
});
}
async markAsRead(notificationId: number, userId: number): Promise<void> {
await this.notificationRepo.update(
{ id: notificationId, user_id: userId },
{ is_read: true, read_at: new Date() }
);
}
async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update(
{ user_id: userId, is_read: false },
{ is_read: true, read_at: new Date() }
);
}
}
```
### 3. Email Queue Processor
```typescript
// File: backend/src/modules/notification/processors/email.processor.ts
import { Processor, Process } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import * as nodemailer from 'nodemailer';
import * as handlebars from 'handlebars';
@Processor('email')
export class EmailProcessor {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
@Process('send-email')
async sendEmail(job: Job<any>) {
const { to, subject, template, context } = job.data;
// Load template
const templatePath = `./templates/emails/${template}.hbs`;
const templateSource = await fs.readFile(templatePath, 'utf-8');
const compiledTemplate = handlebars.compile(templateSource);
const html = compiledTemplate(context);
// Send email
await this.transporter.sendMail({
from: process.env.SMTP_FROM,
to,
subject,
html,
});
}
}
```
### 4. LINE Notify Processor
```typescript
// File: backend/src/modules/notification/processors/line-notify.processor.ts
@Processor('line-notify')
export class LineNotifyProcessor {
@Process('send-line')
async sendLineNotify(job: Job<any>) {
const { token, message } = job.data;
await axios.post(
'https://notify-api.line.me/api/notify',
`message=${encodeURIComponent(message)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
}
);
}
}
```
### 5. Audit Log Service
```typescript
// File: backend/src/modules/audit/audit.service.ts
@Injectable()
export class AuditService {
constructor(
@InjectRepository(AuditLog)
private auditRepo: Repository<AuditLog>
) {}
async log(dto: CreateAuditLogDto): Promise<void> {
const auditLog = this.auditRepo.create({
user_id: dto.user_id,
action: dto.action,
entity_type: dto.entity_type,
entity_id: dto.entity_id,
changes: dto.changes,
ip_address: dto.ip_address,
user_agent: dto.user_agent,
});
await this.auditRepo.save(auditLog);
}
async findByEntity(
entityType: string,
entityId: number
): Promise<AuditLog[]> {
return this.auditRepo.find({
where: { entity_type: entityType, entity_id: entityId },
relations: ['user'],
order: { created_at: 'DESC' },
});
}
async findByUser(userId: number, limit: number = 100): Promise<AuditLog[]> {
return this.auditRepo.find({
where: { user_id: userId },
order: { created_at: 'DESC' },
take: limit,
});
}
async exportToCsv(query: AuditQueryDto): Promise<string> {
const logs = await this.auditRepo.find({
where: this.buildWhereClause(query),
relations: ['user'],
order: { created_at: 'DESC' },
});
// Generate CSV
const csv = logs
.map((log) =>
[
log.created_at,
log.user.username,
log.action,
log.entity_type,
log.entity_id,
log.ip_address,
].join(',')
)
.join('\n');
return `Timestamp,User,Action,Entity Type,Entity ID,IP Address\n${csv}`;
}
}
```
### 6. Audit Interceptor
```typescript
// File: backend/src/common/interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private auditService: AuditService) {}
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const { method, url, user, ip, headers } = request;
// Only audit write operations
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return next.handle();
}
return next.handle().pipe(
tap(async (response) => {
// Extract entity info from URL
const match = url.match(/\/(\w+)\/(\d+)?/);
if (match) {
const [, entityType, entityId] = match;
await this.auditService.log({
user_id: user?.user_id,
action: `${method} ${entityType}`,
entity_type: entityType,
entity_id: entityId ? parseInt(entityId) : null,
changes: JSON.stringify(request.body),
ip_address: ip,
user_agent: headers['user-agent'],
});
}
})
);
}
}
```
### 7. Controllers
```typescript
// File: backend/src/modules/notification/notification.controller.ts
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationController {
constructor(private service: NotificationService) {}
@Get('my')
async getMyNotifications(
@CurrentUser() user: User,
@Query('unread_only') unreadOnly: boolean
) {
return this.service.getUserNotifications(user.user_id, unreadOnly);
}
@Post(':id/read')
async markAsRead(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
) {
return this.service.markAsRead(id, user.user_id);
}
@Post('read-all')
async markAllAsRead(@CurrentUser() user: User) {
return this.service.markAllAsRead(user.user_id);
}
}
```
```typescript
// File: backend/src/modules/audit/audit.controller.ts
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class AuditController {
constructor(private service: AuditService) {}
@Get('entity/:type/:id')
@RequirePermission('audit.view')
async getEntityAuditLogs(
@Param('type') type: string,
@Param('id', ParseIntPipe) id: number
) {
return this.service.findByEntity(type, id);
}
@Get('export')
@RequirePermission('audit.export')
async exportAuditLogs(@Query() query: AuditQueryDto, @Res() res: Response) {
const csv = await this.service.exportToCsv(query);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=audit-logs.csv');
res.send(csv);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('NotificationService', () => {
it('should create in-app notification', async () => {
const result = await service.createNotification({
user_id: 1,
type: 'info',
title: 'Test',
message: 'Test message',
});
expect(result.id).toBeDefined();
});
it('should queue email for sending', async () => {
await service.sendEmail({
to: 'test@example.com',
subject: 'Test',
template: 'test',
context: {},
});
expect(emailQueue.add).toHaveBeenCalled();
});
});
describe('AuditService', () => {
it('should log audit event', async () => {
await service.log({
user_id: 1,
action: 'CREATE correspondence',
entity_type: 'correspondence',
entity_id: 10,
});
const logs = await service.findByEntity('correspondence', 10);
expect(logs).toHaveLength(1);
});
});
```
---
## 📚 Related Documents
- [System Architecture - Notifications](../02-architecture/system-architecture.md#notifications)
---
## 📦 Deliverables
- [ ] NotificationService (Email, LINE, In-App)
- [ ] Email & LINE Queue Processors
- [ ] Email Templates (Handlebars)
- [ ] AuditService
- [ ] Audit Interceptor
- [ ] Controllers
- [ ] Unit Tests (75% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| --------------------- | ------ | -------------------------- |
| Email service down | Low | Queue retry logic |
| LINE token expiration | Low | Token refresh mechanism |
| Audit log volume | Medium | Archive old logs, indexing |
---
## 📌 Notes
- Email sent via queue (async)
- LINE Notify requires user token setup
- In-app notifications stored in DB
- Audit logs auto-generated via interceptor
- Export audit logs to CSV
- Email templates use Handlebars

View File

@@ -0,0 +1,641 @@
# Task: Master Data Management Module
**Status:** Not Started
**Priority:** P1 (High - Required for System Setup)
**Estimated Effort:** 6-8 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Master Data Management Module สำหรับจัดการข้อมูลหลักของระบบ ที่ใช้สำหรับ Configuration และ Dropdown Lists
---
## 🎯 Objectives
- ✅ Organization Management (CRUD)
- ✅ Project & Contract Management
- ✅ Type/Category Management
- ✅ Discipline Management
- ✅ Code Management (RFA Approve Codes, etc.)
- ✅ User Preferences
---
## 📝 Acceptance Criteria
1. **Organization Management:**
- ✅ Create/Update/Delete organizations
- ✅ Active/Inactive toggle
- ✅ Organization hierarchy (if needed)
- ✅ Unique organization codes
2. **Project & Contract Management:**
- ✅ Create/Update/Delete projects
- ✅ Link projects to organizations
- ✅ Create/Update/Delete contracts
- ✅ Link contracts to projects
3. **Type Management:**
- ✅ Correspondence Types CRUD
- ✅ RFA Types CRUD
- ✅ Drawing Categories CRUD
- ✅ Correspondence Sub Types CRUD
4. **Discipline Management:**
- ✅ Create/Update disciplines
- ✅ Discipline codes (GEN, STR, ARC, etc.)
- ✅ Active/Inactive status
5. **Code Management:**
- ✅ RFA Approve Codes CRUD
- ✅ Other lookup codes
---
## 🛠️ Implementation Steps
### 1. Organization Module
```typescript
// File: backend/src/modules/master-data/organization/organization.service.ts
@Injectable()
export class OrganizationService {
constructor(
@InjectRepository(Organization)
private orgRepo: Repository<Organization>
) {}
async create(dto: CreateOrganizationDto): Promise<Organization> {
// Check unique code
const existing = await this.orgRepo.findOne({
where: { organization_code: dto.organization_code },
});
if (existing) {
throw new ConflictException('Organization code already exists');
}
const organization = this.orgRepo.create({
organization_code: dto.organization_code,
organization_name: dto.organization_name,
organization_name_en: dto.organization_name_en,
address: dto.address,
phone: dto.phone,
email: dto.email,
is_active: true,
});
return this.orgRepo.save(organization);
}
async update(id: number, dto: UpdateOrganizationDto): Promise<Organization> {
const organization = await this.findOne(id);
// Check unique code if changed
if (
dto.organization_code &&
dto.organization_code !== organization.organization_code
) {
const existing = await this.orgRepo.findOne({
where: { organization_code: dto.organization_code },
});
if (existing) {
throw new ConflictException('Organization code already exists');
}
}
Object.assign(organization, dto);
return this.orgRepo.save(organization);
}
async findAll(includeInactive: boolean = false): Promise<Organization[]> {
const where: any = {};
if (!includeInactive) {
where.is_active = true;
}
return this.orgRepo.find({
where,
order: { organization_code: 'ASC' },
});
}
async findOne(id: number): Promise<Organization> {
const organization = await this.orgRepo.findOne({ where: { id } });
if (!organization) {
throw new NotFoundException(`Organization #${id} not found`);
}
return organization;
}
async toggleActive(id: number): Promise<Organization> {
const organization = await this.findOne(id);
organization.is_active = !organization.is_active;
return this.orgRepo.save(organization);
}
async delete(id: number): Promise<void> {
// Check if organization has any related data
const hasProjects = await this.hasRelatedProjects(id);
if (hasProjects) {
throw new BadRequestException(
'Cannot delete organization with related projects'
);
}
await this.orgRepo.softDelete(id);
}
private async hasRelatedProjects(organizationId: number): Promise<boolean> {
const count = await this.orgRepo
.createQueryBuilder('org')
.leftJoin(
'projects',
'p',
'p.client_organization_id = org.id OR p.consultant_organization_id = org.id'
)
.where('org.id = :id', { id: organizationId })
.getCount();
return count > 0;
}
}
```
### 2. Project & Contract Module
```typescript
// File: backend/src/modules/master-data/project/project.service.ts
@Injectable()
export class ProjectService {
constructor(
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(Contract)
private contractRepo: Repository<Contract>
) {}
async createProject(dto: CreateProjectDto): Promise<Project> {
const project = this.projectRepo.create({
project_code: dto.project_code,
project_name: dto.project_name,
project_name_en: dto.project_name_en,
client_organization_id: dto.client_organization_id,
consultant_organization_id: dto.consultant_organization_id,
start_date: dto.start_date,
end_date: dto.end_date,
is_active: true,
});
return this.projectRepo.save(project);
}
async createContract(dto: CreateContractDto): Promise<Contract> {
// Verify project exists
const project = await this.projectRepo.findOne({
where: { id: dto.project_id },
});
if (!project) {
throw new NotFoundException(`Project #${dto.project_id} not found`);
}
const contract = this.contractRepo.create({
contract_number: dto.contract_number,
contract_name: dto.contract_name,
project_id: dto.project_id,
contractor_organization_id: dto.contractor_organization_id,
start_date: dto.start_date,
end_date: dto.end_date,
contract_value: dto.contract_value,
is_active: true,
});
return this.contractRepo.save(contract);
}
async findAllProjects(): Promise<Project[]> {
return this.projectRepo.find({
where: { is_active: true },
relations: ['clientOrganization', 'consultantOrganization', 'contracts'],
order: { project_code: 'ASC' },
});
}
async findProjectContracts(projectId: number): Promise<Contract[]> {
return this.contractRepo.find({
where: { project_id: projectId, is_active: true },
relations: ['contractorOrganization'],
order: { contract_number: 'ASC' },
});
}
}
```
### 3. Type Management Service
```typescript
// File: backend/src/modules/master-data/type/type.service.ts
@Injectable()
export class TypeService {
constructor(
@InjectRepository(CorrespondenceType)
private corrTypeRepo: Repository<CorrespondenceType>,
@InjectRepository(RfaType)
private rfaTypeRepo: Repository<RfaType>,
@InjectRepository(DrawingCategory)
private drawingCategoryRepo: Repository<DrawingCategory>,
@InjectRepository(CorrespondenceSubType)
private corrSubTypeRepo: Repository<CorrespondenceSubType>
) {}
// Correspondence Types
async createCorrespondenceType(
dto: CreateTypeDto
): Promise<CorrespondenceType> {
const type = this.corrTypeRepo.create({
type_code: dto.type_code,
type_name: dto.type_name,
is_active: true,
});
return this.corrTypeRepo.save(type);
}
async findAllCorrespondenceTypes(): Promise<CorrespondenceType[]> {
return this.corrTypeRepo.find({
where: { is_active: true },
order: { type_code: 'ASC' },
});
}
// RFA Types
async createRfaType(dto: CreateTypeDto): Promise<RfaType> {
const type = this.rfaTypeRepo.create({
type_code: dto.type_code,
type_name: dto.type_name,
is_active: true,
});
return this.rfaTypeRepo.save(type);
}
async findAllRfaTypes(): Promise<RfaType[]> {
return this.rfaTypeRepo.find({
where: { is_active: true },
order: { type_code: 'ASC' },
});
}
// Drawing Categories
async createDrawingCategory(dto: CreateTypeDto): Promise<DrawingCategory> {
const category = this.drawingCategoryRepo.create({
category_code: dto.type_code,
category_name: dto.type_name,
is_active: true,
});
return this.drawingCategoryRepo.save(category);
}
async findAllDrawingCategories(): Promise<DrawingCategory[]> {
return this.drawingCategoryRepo.find({
where: { is_active: true },
order: { category_code: 'ASC' },
});
}
// Correspondence Sub Types
async createCorrespondenceSubType(
dto: CreateSubTypeDto
): Promise<CorrespondenceSubType> {
const subType = this.corrSubTypeRepo.create({
correspondence_type_id: dto.correspondence_type_id,
sub_type_code: dto.sub_type_code,
sub_type_name: dto.sub_type_name,
is_active: true,
});
return this.corrSubTypeRepo.save(subType);
}
async findCorrespondenceSubTypes(
typeId: number
): Promise<CorrespondenceSubType[]> {
return this.corrSubTypeRepo.find({
where: { correspondence_type_id: typeId, is_active: true },
order: { sub_type_code: 'ASC' },
});
}
}
```
### 4. Discipline Management
```typescript
// File: backend/src/modules/master-data/discipline/discipline.service.ts
@Injectable()
export class DisciplineService {
constructor(
@InjectRepository(Discipline)
private disciplineRepo: Repository<Discipline>
) {}
async create(dto: CreateDisciplineDto): Promise<Discipline> {
const existing = await this.disciplineRepo.findOne({
where: { discipline_code: dto.discipline_code },
});
if (existing) {
throw new ConflictException('Discipline code already exists');
}
const discipline = this.disciplineRepo.create({
discipline_code: dto.discipline_code,
discipline_name: dto.discipline_name,
is_active: true,
});
return this.disciplineRepo.save(discipline);
}
async findAll(): Promise<Discipline[]> {
return this.disciplineRepo.find({
where: { is_active: true },
order: { discipline_code: 'ASC' },
});
}
async update(id: number, dto: UpdateDisciplineDto): Promise<Discipline> {
const discipline = await this.disciplineRepo.findOne({ where: { id } });
if (!discipline) {
throw new NotFoundException(`Discipline #${id} not found`);
}
Object.assign(discipline, dto);
return this.disciplineRepo.save(discipline);
}
}
```
### 5. RFA Approve Codes
```typescript
// File: backend/src/modules/master-data/code/code.service.ts
@Injectable()
export class CodeService {
constructor(
@InjectRepository(RfaApproveCode)
private rfaApproveCodeRepo: Repository<RfaApproveCode>
) {}
async createRfaApproveCode(
dto: CreateApproveCodeDto
): Promise<RfaApproveCode> {
const code = this.rfaApproveCodeRepo.create({
code: dto.code,
description: dto.description,
is_active: true,
});
return this.rfaApproveCodeRepo.save(code);
}
async findAllRfaApproveCodes(): Promise<RfaApproveCode[]> {
return this.rfaApproveCodeRepo.find({
where: { is_active: true },
order: { code: 'ASC' },
});
}
}
```
### 6. Master Data Controller
```typescript
// File: backend/src/modules/master-data/master-data.controller.ts
@Controller('master-data')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Master Data')
export class MasterDataController {
constructor(
private organizationService: OrganizationService,
private projectService: ProjectService,
private typeService: TypeService,
private disciplineService: DisciplineService,
private codeService: CodeService
) {}
// Organizations
@Get('organizations')
async getOrganizations() {
return this.organizationService.findAll();
}
@Post('organizations')
@RequirePermission('master_data.manage')
async createOrganization(@Body() dto: CreateOrganizationDto) {
return this.organizationService.create(dto);
}
@Put('organizations/:id')
@RequirePermission('master_data.manage')
async updateOrganization(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateOrganizationDto
) {
return this.organizationService.update(id, dto);
}
// Projects
@Get('projects')
async getProjects() {
return this.projectService.findAllProjects();
}
@Post('projects')
@RequirePermission('master_data.manage')
async createProject(@Body() dto: CreateProjectDto) {
return this.projectService.createProject(dto);
}
// Contracts
@Get('projects/:projectId/contracts')
async getProjectContracts(
@Param('projectId', ParseIntPipe) projectId: number
) {
return this.projectService.findProjectContracts(projectId);
}
@Post('contracts')
@RequirePermission('master_data.manage')
async createContract(@Body() dto: CreateContractDto) {
return this.projectService.createContract(dto);
}
// Correspondence Types
@Get('correspondence-types')
async getCorrespondenceTypes() {
return this.typeService.findAllCorrespondenceTypes();
}
@Post('correspondence-types')
@RequirePermission('master_data.manage')
async createCorrespondenceType(@Body() dto: CreateTypeDto) {
return this.typeService.createCorrespondenceType(dto);
}
// RFA Types
@Get('rfa-types')
async getRfaTypes() {
return this.typeService.findAllRfaTypes();
}
// Disciplines
@Get('disciplines')
async getDisciplines() {
return this.disciplineService.findAll();
}
@Post('disciplines')
@RequirePermission('master_data.manage')
async createDiscipline(@Body() dto: CreateDisciplineDto) {
return this.disciplineService.create(dto);
}
// RFA Approve Codes
@Get('rfa-approve-codes')
async getRfaApproveCodes() {
return this.codeService.findAllRfaApproveCodes();
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('OrganizationService', () => {
it('should create organization with unique code', async () => {
const dto = {
organization_code: 'TEST',
organization_name: 'Test Organization',
};
const result = await service.create(dto);
expect(result.organization_code).toBe('TEST');
expect(result.is_active).toBe(true);
});
it('should throw error when creating duplicate code', async () => {
await expect(
service.create({
organization_code: 'TEAM',
organization_name: 'Duplicate',
})
).rejects.toThrow(ConflictException);
});
it('should prevent deletion of organization with projects', async () => {
await expect(service.delete(1)).rejects.toThrow(BadRequestException);
});
});
describe('ProjectService', () => {
it('should create project with contracts', async () => {
const project = await service.createProject({
project_code: 'LCBP3',
project_name: 'Laem Chabang Phase 3',
client_organization_id: 1,
consultant_organization_id: 2,
});
expect(project.project_code).toBe('LCBP3');
});
});
```
### 2. Integration Tests
```bash
# Get all organizations
curl http://localhost:3000/master-data/organizations
# Create organization
curl -X POST http://localhost:3000/master-data/organizations \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"organization_code": "ABC",
"organization_name": "ABC Company"
}'
# Get projects
curl http://localhost:3000/master-data/projects
# Get disciplines
curl http://localhost:3000/master-data/disciplines
```
---
## 📚 Related Documents
- [Data Model - Master Data](../02-architecture/data-model.md#core--master-data)
- [Data Dictionary v1.4.5](../../docs/4_Data_Dictionary_V1_4_5.md)
---
## 📦 Deliverables
- [ ] OrganizationService (CRUD)
- [ ] ProjectService & ContractService
- [ ] TypeService (Correspondence, RFA, Drawing)
- [ ] DisciplineService
- [ ] CodeService (RFA Approve Codes)
- [ ] MasterDataController (unified endpoints)
- [ ] DTOs for all entities
- [ ] Unit Tests (80% coverage)
- [ ] Integration Tests
- [ ] API Documentation (Swagger)
- [ ] Seed data scripts
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ----------------------- | ------ | --------------------------------- |
| Duplicate codes | Medium | Unique constraints + validation |
| Circular dependencies | Low | Proper foreign key design |
| Deletion with relations | High | Check relations before delete |
| Data integrity | High | Use transactions for related data |
---
## 📌 Notes
- All master data tables have `is_active` flag
- Soft delete for organizations and projects
- Unique codes enforced at database level
- Organization deletion checks for related projects
- Seed data required for initial setup
- Admin-only access for create/update/delete
- Public read access for dropdown lists
- Cache frequently accessed master data (Redis)

View File

@@ -0,0 +1,738 @@
# Task: User Management Module
**Status:** Not Started
**Priority:** P1 (High - Core User Features)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth & RBAC)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง User Management Module สำหรับจัดการ Users, User Profiles, Password Management, และ User Preferences
---
## 🎯 Objectives
- ✅ User CRUD Operations
- ✅ User Profile Management
- ✅ Password Change & Reset
- ✅ User Preferences (Settings)
- ✅ User Avatar Upload
- ✅ User Search & Filter
---
## 📝 Acceptance Criteria
1. **User Management:**
- ✅ Create user with default password
- ✅ Update user information
- ✅ Activate/Deactivate users
- ✅ Soft delete users
- ✅ Search users by name/email/username
2. **Profile Management:**
- ✅ User can view own profile
- ✅ User can update own profile
- ✅ Upload avatar/profile picture
- ✅ Change display name
3. **Password Management:**
- ✅ Change password (authenticated)
- ✅ Reset password (forgot password flow)
- ✅ Password strength validation
- ✅ Password history (prevent reuse)
4. **User Preferences:**
- ✅ Email notification settings
- ✅ LINE Notify token
- ✅ Language preference (TH/EN)
- ✅ Timezone settings
---
## 🛠️ Implementation Steps
### 1. User Service
```typescript
// File: backend/src/modules/user/user.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(UserPreference)
private preferenceRepo: Repository<UserPreference>,
private fileStorage: FileStorageService
) {}
async create(dto: CreateUserDto): Promise<User> {
// Check unique username and email
const existingUsername = await this.userRepo.findOne({
where: { username: dto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
const existingEmail = await this.userRepo.findOne({
where: { email: dto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
// Hash default password
const defaultPassword = dto.password || this.generateRandomPassword();
const passwordHash = await bcrypt.hash(defaultPassword, 10);
// Create user
const user = this.userRepo.create({
username: dto.username,
email: dto.email,
first_name: dto.first_name,
last_name: dto.last_name,
organization_id: dto.organization_id,
password_hash: passwordHash,
is_active: true,
must_change_password: true, // Force password change on first login
});
await this.userRepo.save(user);
// Create default preferences
await this.createDefaultPreferences(user.user_id);
return user;
}
async update(id: number, dto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
// Check unique email if changed
if (dto.email && dto.email !== user.email) {
const existing = await this.userRepo.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException('Email already exists');
}
}
Object.assign(user, dto);
return this.userRepo.save(user);
}
async findAll(query: SearchUserDto): Promise<PaginatedResult<User>> {
const queryBuilder = this.userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.organization', 'org')
.where('user.deleted_at IS NULL');
// Search filters
if (query.search) {
queryBuilder.andWhere(
'(user.username LIKE :search OR user.email LIKE :search OR ' +
'user.first_name LIKE :search OR user.last_name LIKE :search)',
{ search: `%${query.search}%` }
);
}
if (query.organization_id) {
queryBuilder.andWhere('user.organization_id = :orgId', {
orgId: query.organization_id,
});
}
if (query.is_active !== undefined) {
queryBuilder.andWhere('user.is_active = :isActive', {
isActive: query.is_active,
});
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('user.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
// Remove sensitive data
items.forEach((user) => this.sanitizeUser(user));
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: number): Promise<User> {
const user = await this.userRepo.findOne({
where: { user_id: id, deleted_at: IsNull() },
relations: ['organization'],
});
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return this.sanitizeUser(user);
}
async toggleActive(id: number): Promise<User> {
const user = await this.userRepo.findOne({ where: { user_id: id } });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
user.is_active = !user.is_active;
return this.userRepo.save(user);
}
async softDelete(id: number): Promise<void> {
const user = await this.findOne(id);
// Prevent deletion of users with active sessions or critical roles
const hasActiveSessions = await this.hasActiveSessions(id);
if (hasActiveSessions) {
throw new BadRequestException('Cannot delete user with active sessions');
}
await this.userRepo.softDelete(id);
}
private sanitizeUser(user: User): User {
delete user.password_hash;
return user;
}
private generateRandomPassword(): string {
return (
Math.random().toString(36).slice(-8) +
Math.random().toString(36).slice(-8)
);
}
private async createDefaultPreferences(userId: number): Promise<void> {
const preferences = this.preferenceRepo.create({
user_id: userId,
language: 'th',
timezone: 'Asia/Bangkok',
email_notifications_enabled: true,
line_notify_enabled: false,
});
await this.preferenceRepo.save(preferences);
}
private async hasActiveSessions(userId: number): Promise<boolean> {
// Check Redis for active sessions
// Implementation depends on session management strategy
return false;
}
}
```
### 2. Profile Service
```typescript
// File: backend/src/modules/user/profile.service.ts
@Injectable()
export class ProfileService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(UserPreference)
private preferenceRepo: Repository<UserPreference>,
private fileStorage: FileStorageService
) {}
async getProfile(userId: number): Promise<UserProfile> {
const user = await this.userRepo.findOne({
where: { user_id: userId },
relations: ['organization', 'preferences'],
});
if (!user) {
throw new NotFoundException('User not found');
}
const preferences = await this.preferenceRepo.findOne({
where: { user_id: userId },
});
return {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
display_name: user.display_name,
organization: user.organization,
avatar_url: user.avatar_url,
preferences,
};
}
async updateProfile(userId: number, dto: UpdateProfileDto): Promise<User> {
const user = await this.userRepo.findOne({ where: { user_id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Update allowed fields only
if (dto.first_name) user.first_name = dto.first_name;
if (dto.last_name) user.last_name = dto.last_name;
if (dto.display_name) user.display_name = dto.display_name;
if (dto.phone) user.phone = dto.phone;
return this.userRepo.save(user);
}
async uploadAvatar(
userId: number,
file: Express.Multer.File
): Promise<string> {
// Upload to temp storage
const uploadResult = await this.fileStorage.uploadToTemp(file, userId);
// Commit to permanent storage
const attachments = await this.fileStorage.commitFiles(
[uploadResult.temp_id],
userId,
'user_avatar',
this.userRepo.manager
);
const avatarUrl = `/attachments/${attachments[0].id}`;
// Update user avatar_url
await this.userRepo.update(userId, { avatar_url: avatarUrl });
return avatarUrl;
}
async updatePreferences(
userId: number,
dto: UpdatePreferencesDto
): Promise<UserPreference> {
let preferences = await this.preferenceRepo.findOne({
where: { user_id: userId },
});
if (!preferences) {
preferences = this.preferenceRepo.create({ user_id: userId });
}
Object.assign(preferences, dto);
return this.preferenceRepo.save(preferences);
}
}
```
### 3. Password Service
```typescript
// File: backend/src/modules/user/password.service.ts
@Injectable()
export class PasswordService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(PasswordHistory)
private passwordHistoryRepo: Repository<PasswordHistory>,
private redis: Redis,
private emailQueue: Queue
) {}
async changePassword(userId: number, dto: ChangePasswordDto): Promise<void> {
const user = await this.userRepo.findOne({ where: { user_id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Verify current password
const isValid = await bcrypt.compare(
dto.current_password,
user.password_hash
);
if (!isValid) {
throw new BadRequestException('Current password is incorrect');
}
// Validate new password strength
this.validatePasswordStrength(dto.new_password);
// Check password history (prevent reuse of last 5 passwords)
await this.checkPasswordHistory(userId, dto.new_password);
// Hash new password
const newPasswordHash = await bcrypt.hash(dto.new_password, 10);
// Update password
user.password_hash = newPasswordHash;
user.must_change_password = false;
user.password_changed_at = new Date();
await this.userRepo.save(user);
// Save to password history
await this.passwordHistoryRepo.save({
user_id: userId,
password_hash: newPasswordHash,
});
// Invalidate all existing sessions
await this.invalidateUserSessions(userId);
}
async requestPasswordReset(email: string): Promise<void> {
const user = await this.userRepo.findOne({ where: { email } });
if (!user) {
// Don't reveal if email exists
return;
}
// Generate reset token
const resetToken = this.generateResetToken();
const resetTokenHash = await bcrypt.hash(resetToken, 10);
// Store token in Redis (expires in 1 hour)
await this.redis.set(
`password_reset:${user.user_id}`,
resetTokenHash,
'EX',
3600
);
// Send reset email
await this.emailQueue.add('send-password-reset', {
to: user.email,
resetToken,
username: user.username,
});
}
async resetPassword(dto: ResetPasswordDto): Promise<void> {
const user = await this.userRepo.findOne({
where: { username: dto.username },
});
if (!user) {
throw new BadRequestException('Invalid reset token');
}
// Verify reset token
const storedTokenHash = await this.redis.get(
`password_reset:${user.user_id}`
);
if (!storedTokenHash) {
throw new BadRequestException('Reset token expired');
}
const isValid = await bcrypt.compare(dto.reset_token, storedTokenHash);
if (!isValid) {
throw new BadRequestException('Invalid reset token');
}
// Validate new password
this.validatePasswordStrength(dto.new_password);
// Hash and update password
const newPasswordHash = await bcrypt.hash(dto.new_password, 10);
user.password_hash = newPasswordHash;
user.password_changed_at = new Date();
await this.userRepo.save(user);
// Delete reset token
await this.redis.del(`password_reset:${user.user_id}`);
// Invalidate sessions
await this.invalidateUserSessions(user.user_id);
}
private validatePasswordStrength(password: string): void {
if (password.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
// Check for at least one uppercase, one lowercase, one number
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
throw new BadRequestException(
'Password must contain uppercase, lowercase, and numbers'
);
}
}
private async checkPasswordHistory(
userId: number,
newPassword: string
): Promise<void> {
const history = await this.passwordHistoryRepo.find({
where: { user_id: userId },
order: { changed_at: 'DESC' },
take: 5,
});
for (const record of history) {
const isSame = await bcrypt.compare(newPassword, record.password_hash);
if (isSame) {
throw new BadRequestException('Cannot reuse recently used passwords');
}
}
}
private generateResetToken(): string {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
}
private async invalidateUserSessions(userId: number): Promise<void> {
await this.redis.del(`user:${userId}:permissions`);
await this.redis.del(`refresh_token:${userId}`);
}
}
```
### 4. User Controller
```typescript
// File: backend/src/modules/user/user.controller.ts
@Controller('users')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Users')
export class UserController {
constructor(
private userService: UserService,
private profileService: ProfileService,
private passwordService: PasswordService
) {}
// User Management (Admin)
@Get()
@RequirePermission('user.view')
async findAll(@Query() query: SearchUserDto) {
return this.userService.findAll(query);
}
@Post()
@RequirePermission('user.create')
async create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
@Put(':id')
@RequirePermission('user.update')
async update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateUserDto
) {
return this.userService.update(id, dto);
}
@Post(':id/toggle-active')
@RequirePermission('user.update')
async toggleActive(@Param('id', ParseIntPipe) id: number) {
return this.userService.toggleActive(id);
}
@Delete(':id')
@RequirePermission('user.delete')
@HttpCode(204)
async delete(@Param('id', ParseIntPipe) id: number) {
return this.userService.softDelete(id);
}
// Profile Management (Self)
@Get('me/profile')
async getMyProfile(@CurrentUser() user: User) {
return this.profileService.getProfile(user.user_id);
}
@Put('me/profile')
async updateMyProfile(
@CurrentUser() user: User,
@Body() dto: UpdateProfileDto
) {
return this.profileService.updateProfile(user.user_id, dto);
}
@Post('me/avatar')
@UseInterceptors(FileInterceptor('avatar'))
async uploadAvatar(
@CurrentUser() user: User,
@UploadedFile() file: Express.Multer.File
) {
return this.profileService.uploadAvatar(user.user_id, file);
}
@Put('me/preferences')
async updatePreferences(
@CurrentUser() user: User,
@Body() dto: UpdatePreferencesDto
) {
return this.profileService.updatePreferences(user.user_id, dto);
}
// Password Management
@Post('me/change-password')
async changePassword(
@CurrentUser() user: User,
@Body() dto: ChangePasswordDto
) {
return this.passwordService.changePassword(user.user_id, dto);
}
@Post('request-password-reset')
@Public() // No auth required
async requestPasswordReset(@Body() dto: RequestPasswordResetDto) {
return this.passwordService.requestPasswordReset(dto.email);
}
@Post('reset-password')
@Public() // No auth required
async resetPassword(@Body() dto: ResetPasswordDto) {
return this.passwordService.resetPassword(dto);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('UserService', () => {
it('should create user with hashed password', async () => {
const dto = {
username: 'testuser',
email: 'test@example.com',
first_name: 'Test',
last_name: 'User',
organization_id: 1,
};
const result = await service.create(dto);
expect(result.username).toBe('testuser');
expect(result.password_hash).toBeUndefined(); // Sanitized
expect(result.must_change_password).toBe(true);
});
it('should prevent duplicate username', async () => {
await expect(
service.create({ username: 'admin', email: 'new@example.com' })
).rejects.toThrow(ConflictException);
});
});
describe('PasswordService', () => {
it('should change password successfully', async () => {
await service.changePassword(1, {
current_password: 'oldPassword123',
new_password: 'NewPassword123',
});
// Verify password updated
});
it('should prevent password reuse', async () => {
await expect(
service.changePassword(1, {
current_password: 'current',
new_password: 'previouslyUsed',
})
).rejects.toThrow('Cannot reuse recently used passwords');
});
it('should validate password strength', async () => {
await expect(
service.changePassword(1, {
current_password: 'current',
new_password: 'weak',
})
).rejects.toThrow('Password must be at least 8 characters');
});
});
```
---
## 📚 Related Documents
- [Data Model - Users](../02-architecture/data-model.md#users--rbac)
- [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
---
## 📦 Deliverables
- [ ] UserService (CRUD)
- [ ] ProfileService (Profile & Avatar)
- [ ] PasswordService (Change & Reset)
- [ ] UserController
- [ ] DTOs (Create, Update, Profile, Password)
- [ ] Password History tracking
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| -------------------- | -------- | --------------------------------------- |
| Password reset abuse | High | Rate limiting, token expiration |
| Session hijacking | Critical | Session invalidation on password change |
| Weak passwords | High | Password strength validation |
| Email not delivered | Medium | Logging + retry mechanism |
---
## 📌 Notes
- Default password generated on user creation
- Force password change on first login
- Password history prevents reuse (last 5 passwords)
- Reset token expires in 1 hour
- All sessions invalidated on password change
- Avatar uploaded via two-phase storage
- User preferences stored separately
- Soft delete for users
- Admin permission required for user CRUD
- Users can manage own profile without admin permission

View File

@@ -0,0 +1,381 @@
# TASK-FE-001: Frontend Setup & Configuration
**ID:** TASK-FE-001
**Title:** Frontend Project Setup & Configuration
**Category:** Foundation
**Priority:** P0 (Critical)
**Effort:** 2-3 days
**Dependencies:** None
**Assigned To:** Frontend Lead
---
## 📋 Overview
Setup Next.js project with TypeScript, Tailwind CSS, Shadcn/UI, and all necessary tooling for LCBP3-DMS frontend development.
---
## 🎯 Objectives
1. Initialize Next.js 14+ project with App Router
2. Configure TypeScript with strict mode
3. Setup Tailwind CSS and Shadcn/UI
4. Configure ESLint, Prettier, and Husky
5. Setup environment variables
6. Configure API client and interceptors
---
## ✅ Acceptance Criteria
- [ ] Next.js project running on `http://localhost:3001`
- [ ] TypeScript strict mode enabled
- [ ] Shadcn/UI components installable with CLI
- [ ] ESLint and Prettier working
- [ ] Environment variables loaded correctly
- [ ] Axios configured with interceptors
- [ ] Health check endpoint accessible
---
## 🔧 Implementation Steps
### Step 1: Create Next.js Project
```bash
# Create Next.js project with TypeScript
npx create-next-app@latest frontend --typescript --tailwind --app --src-dir --import-alias "@/*"
cd frontend
# Install dependencies
npm install
```
### Step 2: Configure TypeScript
```json
// File: tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```
### Step 3: Setup Tailwind CSS
```javascript
// File: tailwind.config.js
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
// ... more colors
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;
```
### Step 4: Initialize Shadcn/UI
```bash
# Initialize shadcn/ui
npx shadcn-ui@latest init
# Answer prompts:
# - TypeScript: Yes
# - Style: Default
# - Base color: Slate
# - CSS variables: Yes
# - Tailwind config: tailwind.config.js
# - Components: @/components
# - Utils: @/lib/utils
# Install essential components
npx shadcn-ui@latest add button input label card dialog dropdown-menu table
```
### Step 5: Configure ESLint & Prettier
```bash
npm install -D prettier eslint-config-prettier
```
```json
// File: .eslintrc.json
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn"
}
}
```
```json
// File: .prettierrc
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100
}
```
### Step 6: Setup Git Hooks with Husky
```bash
npm install -D husky lint-staged
# Initialize husky
npx husky-init
```
```json
// File: package.json (add to scripts)
{
"scripts": {
"prepare": "husky install"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
}
```
```bash
# File: .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
```
### Step 7: Environment Variables
```bash
# File: .env.local (DO NOT commit)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_APP_NAME=LCBP3-DMS
NEXT_PUBLIC_APP_VERSION=1.5.0
```
```bash
# File: .env.example (commit this)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_APP_NAME=LCBP3-DMS
NEXT_PUBLIC_APP_VERSION=1.5.0
```
```typescript
// File: src/lib/env.ts
export const env = {
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
appName: process.env.NEXT_PUBLIC_APP_NAME!,
appVersion: process.env.NEXT_PUBLIC_APP_VERSION!,
};
// Validate at build time
if (!env.apiUrl) {
throw new Error('NEXT_PUBLIC_API_URL is required');
}
```
### Step 8: Configure API Client
```bash
npm install axios react-query zustand
```
```typescript
// File: src/lib/api/client.ts
import axios from 'axios';
import { env } from '@/lib/env';
export const apiClient = axios.create({
baseURL: env.apiUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Redirect to login
localStorage.removeItem('auth-token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
```
### Step 9: Project Structure
```
frontend/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (public)/ # Public routes
│ │ │ └── login/
│ │ ├── (dashboard)/ # Protected routes
│ │ │ ├── correspondences/
│ │ │ ├── rfas/
│ │ │ └── drawings/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ │
│ ├── components/ # React components
│ │ ├── ui/ # Shadcn/UI components
│ │ ├── layout/ # Layout components
│ │ ├── correspondences/ # Feature components
│ │ └── common/ # Shared components
│ │
│ ├── lib/ # Utilities
│ │ ├── api/ # API clients
│ │ ├── stores/ # Zustand stores
│ │ ├── utils.ts # Helpers
│ │ └── env.ts # Environment
│ │
│ ├── types/ # TypeScript types
│ │ └── index.ts
│ │
│ └── styles/ # Global styles
│ └── globals.css
├── public/ # Static files
├── .env.example
├── .eslintrc.json
├── .prettierrc
├── next.config.js
├── tailwind.config.ts
├── tsconfig.json
└── package.json
```
---
## 🧪 Testing & Verification
### Manual Testing
```bash
# Start dev server
npm run dev
# Check TypeScript
npm run type-check
# Run linter
npm run lint
# Format code
npm run format
```
### Verification Checklist
- [ ] Dev server starts without errors
- [ ] TypeScript compilation succeeds
- [ ] ESLint passes with no errors
- [ ] Tailwind CSS classes working
- [ ] Shadcn/UI components render correctly
- [ ] Environment variables accessible
- [ ] API client configured (test with mock endpoint)
---
## 📦 Deliverables
- [ ] Next.js project initialized
- [ ] TypeScript configured (strict mode)
- [ ] Tailwind CSS working
- [ ] Shadcn/UI installed
- [ ] ESLint & Prettier configured
- [ ] Husky git hooks working
- [ ] Environment variables setup
- [ ] API client configured
- [ ] Project structure documented
---
## 🔗 Related Documents
- [ADR-011: Next.js App Router](../../05-decisions/ADR-011-nextjs-app-router.md)
- [ADR-012: UI Component Library](../../05-decisions/ADR-012-ui-component-library.md)
- [ADR-014: State Management](../../05-decisions/ADR-014-state-management.md)
- [Frontend Guidelines](../../03-implementation/frontend-guidelines.md)
---
## 📝 Notes
- Use App Router (not Pages Router)
- Enable TypeScript strict mode
- Follow Shadcn/UI patterns for components
- Keep bundle size small
---
**Created:** 2025-12-01
**Updated:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,438 @@
# TASK-FE-002: Authentication & Authorization UI
**ID:** TASK-FE-002
**Title:** Login, Session Management & RBAC UI
**Category:** Foundation
**Priority:** P0 (Critical)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-001, TASK-BE-002
**Assigned To:** Frontend Developer
---
## 📋 Overview
Implement authentication UI including login form, session management with Zustand, and permission-based UI rendering.
---
## 🎯 Objectives
1. Create login page with form validation
2. Implement JWT token management
3. Setup Zustand auth store
4. Create protected route middleware
5. Implement permission-based UI components
6. Add logout functionality
---
## ✅ Acceptance Criteria
- [ ] User can login with username/password
- [ ] JWT token stored securely
- [ ] Unauthorized users redirected to login
- [ ] UI elements hidden based on permissions
- [ ] Session persists after page reload
- [ ] Logout clears session
---
## 🔧 Implementation Steps
### Step 1: Create Auth Store (Zustand)
```typescript
// File: src/lib/stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
roles: Array<{
role_name: string;
scope: string;
scope_id: number;
}>;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
logout: () => void;
hasPermission: (permission: string, scope?: string) => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) => {
set({ user, token, isAuthenticated: true });
localStorage.setItem('auth-token', token);
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false });
localStorage.removeItem('auth-token');
},
hasPermission: (permission, scope) => {
const { user } = get();
if (!user) return false;
// Check user roles for permission
return user.roles.some((role) => {
// Permission logic based on RBAC
return true; // Implement actual logic
});
},
}),
{
name: 'auth-storage',
}
)
);
```
### Step 2: Login API Client
```typescript
// File: src/lib/api/auth.ts
import { apiClient } from './client';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
user: {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
roles: any[];
};
}
export const authApi = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post('/auth/login', credentials);
return response.data;
},
logout: async (): Promise<void> => {
await apiClient.post('/auth/logout');
},
getCurrentUser: async () => {
const response = await apiClient.get('/auth/me');
return response.data;
},
};
```
### Step 3: Login Page
```typescript
// File: src/app/(public)/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { authApi } from '@/lib/api/auth';
import { useAuthStore } from '@/lib/stores/auth-store';
const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth);
const [error, setError] = useState('');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
setError('');
const response = await authApi.login(data);
setAuth(response.user, response.access_token);
router.push('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl">LCBP3-DMS Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">
{error}
</div>
)}
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
{...register('username')}
placeholder="Enter username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">
{errors.username.message}
</p>
)}
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...register('password')}
placeholder="Enter password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
```
### Step 4: Protected Route Middleware
```typescript
// File: src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token');
const isPublicPage = request.nextUrl.pathname.startsWith('/login');
if (!token && !isPublicPage) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (token && isPublicPage) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
```
### Step 5: Permission-Based UI Components
```typescript
// File: src/components/common/can.tsx
'use client';
import { useAuthStore } from '@/lib/stores/auth-store';
import { ReactNode } from 'react';
interface CanProps {
permission: string;
scope?: string;
children: ReactNode;
fallback?: ReactNode;
}
export function Can({
permission,
scope,
children,
fallback = null,
}: CanProps) {
const hasPermission = useAuthStore((state) => state.hasPermission);
if (!hasPermission(permission, scope)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
```
```typescript
// Usage example
import { Can } from '@/components/common/can';
<Can permission="correspondence:create">
<Button>Create Correspondence</Button>
</Can>;
```
### Step 6: User Menu Component
```typescript
// File: src/components/layout/user-menu.tsx
'use client';
import { useAuthStore } from '@/lib/stores/auth-store';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useRouter } from 'next/navigation';
export function UserMenu() {
const router = useRouter();
const { user, logout } = useAuthStore();
if (!user) return null;
const handleLogout = () => {
logout();
router.push('/login');
};
const initials = `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">
{user.first_name} {user.last_name}
</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push('/settings')}>
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
```
---
## 🧪 Testing & Verification
### Test Cases
1. **Login Success**
- Enter valid credentials
- User redirected to dashboard
- Token stored
2. **Login Failure**
- Enter invalid credentials
- Error message displayed
- User stays on login page
3. **Protected Routes**
- Access protected route without login → Redirect to login
- Login → Access protected route successfully
4. **Session Persistence**
- Login → Refresh page → Still logged in
5. **Logout**
- Click logout → Token cleared → Redirected to login
6. **Permission-Based UI**
- User with permission sees button
- User without permission doesn't see button
---
## 📦 Deliverables
- [ ] Login page with validation
- [ ] Zustand auth store
- [ ] Auth API client
- [ ] Protected route middleware
- [ ] Permission-based UI components
- [ ] User menu with logout
- [ ] Session persistence
---
## 🔗 Related Documents
- [ADR-014: State Management](../../05-decisions/ADR-014-state-management.md)
- [ADR-013: Form Handling](../../05-decisions/ADR-013-form-handling-validation.md)
- [TASK-BE-002: Auth & RBAC](./TASK-BE-002-auth-rbac.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,346 @@
# TASK-FE-003: Layout & Navigation System
**ID:** TASK-FE-003
**Title:** Dashboard Layout, Sidebar & Navigation
**Category:** Foundation
**Priority:** P0 (Critical)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-001, TASK-FE-002
**Assigned To:** Frontend Developer
---
## 📋 Overview
Create responsive dashboard layout with sidebar navigation, header, and optimized nested layouts using Next.js App Router.
---
## 🎯 Objectives
1. Create responsive dashboard layout
2. Implement sidebar with navigation menu
3. Create header with user menu and breadcrumbs
4. Setup route groups for layout organization
5. Implement mobile-responsive design
6. Add dark mode support (optional)
---
## ✅ Acceptance Criteria
- [ ] Dashboard layout responsive (desktop/tablet/mobile)
- [ ] Sidebar collapsible on mobile
- [ ] Navigation highlights active route
- [ ] Breadcrumbs show current location
- [ ] User menu functional
- [ ] Layout persists across page navigation
---
## 🔧 Implementation Steps
### Step 1: Dashboard Layout
```typescript
// File: src/app/(dashboard)/layout.tsx
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// Server-side auth check
const cookieStore = cookies();
const token = cookieStore.get('auth-token');
if (!token) {
redirect('/login');
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6 bg-gray-50">
{children}
</main>
</div>
</div>
);
}
```
### Step 2: Sidebar Component
```typescript
// File: src/components/layout/sidebar.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
FileText,
Clipboard,
Image,
Send,
Users,
Settings,
Home,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/lib/stores/ui-store';
const menuItems = [
{ href: '/', label: 'Dashboard', icon: Home },
{ href: '/correspondences', label: 'Correspondences', icon: FileText },
{ href: '/rfas', label: 'RFAs', icon: Clipboard },
{ href: '/drawings', label: 'Drawings', icon: Image },
{ href: '/transmittals', label: 'Transmittals', icon: Send },
{ href: '/users', label: 'Users', icon: Users },
{ href: '/settings', label: 'Settings', icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const { sidebarCollapsed, toggleSidebar } = useUIStore();
return (
<aside
className={cn(
'flex flex-col border-r bg-white transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64'
)}
>
{/* Logo */}
<div className="flex h-16 items-center justify-between px-4 border-b">
{!sidebarCollapsed && <h1 className="text-lg font-bold">LCBP3-DMS</h1>}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="ml-auto"
>
<MenuIcon />
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-2">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100'
)}
>
<Icon className="h-5 w-5 flex-shrink-0" />
{!sidebarCollapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
{/* Footer */}
{!sidebarCollapsed && (
<div className="border-t p-4 text-xs text-gray-500">Version 1.5.0</div>
)}
</aside>
);
}
```
### Step 3: Header Component
```typescript
// File: src/components/layout/header.tsx
'use client';
import { Breadcrumbs } from './breadcrumbs';
import { UserMenu } from './user-menu';
import { Button } from '@/components/ui/button';
import { Bell } from 'lucide-react';
export function Header() {
return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<Breadcrumbs />
<div className="flex items-center gap-4">
{/* Notifications */}
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500" />
</Button>
<UserMenu />
</div>
</header>
);
}
```
### Step 4: Breadcrumbs Component
```typescript
// File: src/components/layout/breadcrumbs.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronRight } from 'lucide-react';
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
return (
<nav className="flex items-center space-x-2 text-sm">
<Link href="/" className="text-gray-600 hover:text-gray-900">
Home
</Link>
{segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join('/')}`;
const label = segment.charAt(0).toUpperCase() + segment.slice(1);
const isLast = index === segments.length - 1;
return (
<div key={href} className="flex items-center space-x-2">
<ChevronRight className="h-4 w-4 text-gray-400" />
{isLast ? (
<span className="font-medium text-gray-900">{label}</span>
) : (
<Link href={href} className="text-gray-600 hover:text-gray-900">
{label}
</Link>
)}
</div>
);
})}
</nav>
);
}
```
### Step 5: UI Store (Sidebar State)
```typescript
// File: src/lib/stores/ui-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
}),
{
name: 'ui-preferences',
}
)
);
```
### Step 6: Mobile Responsive
```typescript
// File: src/components/layout/mobile-sidebar.tsx
'use client';
import { useState } from 'react';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Menu } from 'lucide-react';
import { Sidebar } from './sidebar';
export function MobileSidebar() {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-64">
<Sidebar />
</SheetContent>
</Sheet>
);
}
```
---
## 🧪 Testing & Verification
### Manual Testing
1. **Desktop Layout**
- Sidebar visible and functional
- Toggle sidebar collapse/expand
- Active route highlighted
2. **Mobile Layout**
- Sidebar hidden by default
- Hamburger menu opens sidebar
- Sidebar slides from left
3. **Navigation**
- Click menu items → Navigate correctly
- Breadcrumbs update on navigation
- Active state persists on reload
4. **User Menu**
- Display user info
- Logout functional
---
## 📦 Deliverables
- [ ] Dashboard layout for (dashboard) route group
- [ ] Responsive sidebar with navigation
- [ ] Header with breadcrumbs and user menu
- [ ] UI store for sidebar state
- [ ] Mobile-responsive design
- [ ] Icon library (lucide-react)
---
## 🔗 Related Documents
- [ADR-011: Next.js App Router](../../05-decisions/ADR-011-nextjs-app-router.md)
- [ADR-014: State Management](../../05-decisions/ADR-014-state-management.md)
- [TASK-FE-002: Auth UI](./TASK-FE-002-auth-ui.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,406 @@
# TASK-FE-004: Correspondence Management UI
**ID:** TASK-FE-004
**Title:** Correspondence List, Create, View & Edit UI
**Category:** Business Modules
**Priority:** P1 (High)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-003, TASK-BE-005
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build complete UI for Correspondence Management including list view with filters, create/edit forms, detail view, and status workflows.
---
## 🎯 Objectives
1. Create correspondence list with pagination and filters
2. Implement create/edit forms with validation
3. Build detail view with attachments
4. Add status workflow actions (Submit, Approve, Reject)
5. Implement file upload for attachments
6. Add search and filtering
---
## ✅ Acceptance Criteria
- [ ] List displays correspondences with pagination
- [ ] Filter by status, date range, organization
- [ ] Create form validates all required fields
- [ ] File attachments upload successfully
- [ ] Detail view shows complete information
- [ ] Workflow actions work (Submit, Approve, Reject)
- [ ] Real-time status updates
---
## 🔧 Implementation Steps
### Step 1: Correspondence List Page
```typescript
// File: src/app/(dashboard)/correspondences/page.tsx
import { CorrespondenceList } from '@/components/correspondences/list';
import { CorrespondenceFilters } from '@/components/correspondences/filters';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { getCorrespondences } from '@/lib/api/correspondences';
export default async function CorrespondencesPage({
searchParams,
}: {
searchParams: { page?: string; status?: string; search?: string };
}) {
const page = parseInt(searchParams.page || '1');
const data = await getCorrespondences({
page,
status: searchParams.status,
search: searchParams.search,
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Correspondences</h1>
<p className="text-gray-600 mt-1">
Manage official letters and communications
</p>
</div>
<Link href="/correspondences/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Correspondence
</Button>
</Link>
</div>
<CorrespondenceFilters />
<CorrespondenceList data={data} />
</div>
);
}
```
### Step 2: Correspondence List Component
```typescript
// File: src/components/correspondences/list.tsx
'use client';
import { useState } from 'react';
import { Correspondence } from '@/types';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { format } from 'date-fns';
import { Eye, Edit } from 'lucide-react';
import { Pagination } from '@/components/common/pagination';
interface CorrespondenceListProps {
data: {
items: Correspondence[];
total: number;
page: number;
totalPages: number;
};
}
export function CorrespondenceList({ data }: CorrespondenceListProps) {
const getStatusColor = (status: string) => {
const colors = {
DRAFT: 'gray',
PENDING: 'yellow',
IN_REVIEW: 'blue',
APPROVED: 'green',
REJECTED: 'red',
};
return colors[status] || 'gray';
};
return (
<div className="space-y-4">
{data.items.map((item) => (
<Card
key={item.correspondence_id}
className="p-6 hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">{item.subject}</h3>
<Badge variant={getStatusColor(item.status)}>
{item.status}
</Badge>
</div>
<p className="text-sm text-gray-600 mb-3">
{item.description || 'No description'}
</p>
<div className="flex gap-6 text-sm text-gray-500">
<span>
<strong>From:</strong> {item.from_organization?.org_name}
</span>
<span>
<strong>To:</strong> {item.to_organization?.org_name}
</span>
<span>
<strong>Date:</strong>{' '}
{format(new Date(item.created_at), 'dd MMM yyyy')}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/correspondences/${item.correspondence_id}`}>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View
</Button>
</Link>
{item.status === 'DRAFT' && (
<Link href={`/correspondences/${item.correspondence_id}/edit`}>
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
)}
</div>
</div>
</Card>
))}
<Pagination
currentPage={data.page}
totalPages={data.totalPages}
total={data.total}
/>
</div>
);
}
```
### Step 3: Create/Edit Form
```typescript
// File: src/app/(dashboard)/correspondences/new/page.tsx
import { CorrespondenceForm } from '@/components/correspondences/form';
export default function NewCorrespondencePage() {
return (
<div>
<h1 className="text-3xl font-bold mb-6">New Correspondence</h1>
<CorrespondenceForm />
</div>
);
}
```
```typescript
// File: src/components/correspondences/form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FileUpload } from '@/components/common/file-upload';
import { useRouter } from 'next/navigation';
import { correspondenceApi } from '@/lib/api/correspondences';
const correspondenceSchema = z.object({
subject: z.string().min(5, 'Subject must be at least 5 characters'),
description: z.string().optional(),
document_type_id: z.number(),
from_organization_id: z.number(),
to_organization_id: z.number(),
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
attachments: z.array(z.instanceof(File)).optional(),
});
type FormData = z.infer<typeof correspondenceSchema>;
export function CorrespondenceForm() {
const router = useRouter();
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(correspondenceSchema),
});
const onSubmit = async (data: FormData) => {
try {
await correspondenceApi.create(data);
router.push('/correspondences');
} catch (error) {
console.error(error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
{/* Subject */}
<div>
<Label htmlFor="subject">Subject *</Label>
<Input id="subject" {...register('subject')} />
{errors.subject && (
<p className="text-sm text-red-600 mt-1">{errors.subject.message}</p>
)}
</div>
{/* Description */}
<div>
<Label htmlFor="description">Description</Label>
<Textarea id="description" {...register('description')} rows={4} />
</div>
{/* From/To Organizations */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label>From Organization *</Label>
<Select
onValueChange={(v) => setValue('from_organization_id', parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{/* Populate from API */}
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>To Organization *</Label>
<Select
onValueChange={(v) => setValue('to_organization_id', parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Importance */}
<div>
<Label>Importance</Label>
<div className="flex gap-4 mt-2">
<label className="flex items-center">
<input
type="radio"
value="NORMAL"
{...register('importance')}
defaultChecked
/>
<span className="ml-2">Normal</span>
</label>
<label className="flex items-center">
<input type="radio" value="HIGH" {...register('importance')} />
<span className="ml-2">High</span>
</label>
<label className="flex items-center">
<input type="radio" value="URGENT" {...register('importance')} />
<span className="ml-2">Urgent</span>
</label>
</div>
</div>
{/* File Attachments */}
<div>
<Label>Attachments</Label>
<FileUpload
onFilesSelected={(files) => setValue('attachments', files)}
maxFiles={10}
accept=".pdf,.doc,.docx,.xls,.xlsx"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Correspondence'}
</Button>
</div>
</form>
);
}
```
### Step 4: Detail View
```typescript
// File: src/app/(dashboard)/correspondences/[id]/page.tsx
import { getCorrespondenceById } from '@/lib/api/correspondences';
import { CorrespondenceDetail } from '@/components/correspondences/detail';
import { notFound } from 'next/navigation';
export default async function CorrespondenceDetailPage({
params,
}: {
params: { id: string };
}) {
const correspondence = await getCorrespondenceById(parseInt(params.id));
if (!correspondence) {
notFound();
}
return <CorrespondenceDetail data={correspondence} />;
}
```
---
## 📦 Deliverables
- [ ] List page with filters and pagination
- [ ] Create/Edit forms with validation
- [ ] Detail view with complete information
- [ ] File upload component
- [ ] Status workflow actions
- [ ] API client functions
---
## 🔗 Related Documents
- [ADR-013: Form Handling](../../05-decisions/ADR-013-form-handling-validation.md)
- [TASK-BE-005: Correspondence Module](./TASK-BE-005-correspondence-module.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,454 @@
# TASK-FE-005: Common Components & Reusable UI
**ID:** TASK-FE-005
**Title:** Build Reusable UI Components Library
**Category:** Foundation
**Priority:** P1 (High)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-001
**Assigned To:** Frontend Developer
---
## 📋 Overview
Create reusable components including Data Table, File Upload, Date Picker, Pagination, Status Badges, and other common UI elements used across the application.
---
## 🎯 Objectives
1. Build DataTable component with sorting, filtering
2. Create File Upload component with drag-and-drop
3. Implement Date Range Picker
4. Create Pagination component
5. Build Status Badge components
6. Create Confirmation Dialog
7. Implement Toast Notifications
---
## 📦 Deliverables
### 1. Data Table Component
```typescript
// File: src/components/common/data-table.tsx
'use client';
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getSortedRowModel,
SortingState,
} from '@tanstack/react-table';
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting,
},
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
```
### 2. File Upload Component
```typescript
// File: src/components/common/file-upload.tsx
'use client';
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, X, File } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
maxFiles?: number;
accept?: string;
maxSize?: number; // bytes
}
export function FileUpload({
onFilesSelected,
maxFiles = 5,
accept = '.pdf,.doc,.docx',
maxSize = 10485760, // 10MB
}: FileUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const newFiles = [...prev, ...acceptedFiles].slice(0, maxFiles);
onFilesSelected(newFiles);
return newFiles;
});
},
[maxFiles, onFilesSelected]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxFiles,
accept: accept.split(',').reduce((acc, ext) => ({ ...acc, [ext]: [] }), {}),
maxSize,
});
const removeFile = (index: number) => {
setFiles((prev) => {
const newFiles = prev.filter((_, i) => i !== index);
onFilesSelected(newFiles);
return newFiles;
});
};
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
isDragActive
? 'border-primary bg-primary/5'
: 'border-gray-300 hover:border-gray-400'
)}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">
{isDragActive
? 'Drop files here'
: 'Drag & drop files or click to browse'}
</p>
<p className="mt-1 text-xs text-gray-500">
Maximum {maxFiles} files, {(maxSize / 1024 / 1024).toFixed(0)}MB each
</p>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<File className="h-5 w-5 text-gray-500" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
```
### 3. Pagination Component
```typescript
// File: src/components/common/pagination.tsx
'use client';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface PaginationProps {
currentPage: number;
totalPages: number;
total: number;
}
export function Pagination({
currentPage,
totalPages,
total,
}: PaginationProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createPageURL = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
return (
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing page {currentPage} of {totalPages} ({total} total items)
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage - 1))}
disabled={currentPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={pageNum === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => router.push(createPageURL(pageNum))}
>
{pageNum}
</Button>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage + 1))}
disabled={currentPage >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
```
### 4. Status Badge Component
```typescript
// File: src/components/common/status-badge.tsx
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface StatusBadgeProps {
status: string;
className?: string;
}
const statusConfig = {
DRAFT: { label: 'Draft', variant: 'secondary' },
PENDING: { label: 'Pending', variant: 'warning' },
IN_REVIEW: { label: 'In Review', variant: 'info' },
APPROVED: { label: 'Approved', variant: 'success' },
REJECTED: { label: 'Rejected', variant: 'destructive' },
CLOSED: { label: 'Closed', variant: 'outline' },
};
export function StatusBadge({ status, className }: StatusBadgeProps) {
const config = statusConfig[status] || { label: status, variant: 'default' };
return (
<Badge
variant={config.variant as any}
className={cn('uppercase', className)}
>
{config.label}
</Badge>
);
}
```
### 5. Confirmation Dialog
```typescript
// File: src/components/common/confirm-dialog.tsx
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
onConfirm: () => void;
confirmText?: string;
cancelText?: string;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
```
### 6. Toast Notifications
```bash
npx shadcn-ui@latest add toast
```
```typescript
// File: src/lib/stores/toast-store.ts (if not using Shadcn toast)
import { create } from 'zustand';
interface Toast {
id: string;
title: string;
description?: string;
variant: 'default' | 'success' | 'error' | 'warning';
}
interface ToastState {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: Math.random().toString() }],
})),
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
}));
```
---
## 🧪 Testing
- [ ] DataTable sorts columns correctly
- [ ] File upload accepts/rejects files based on criteria
- [ ] Pagination navigates pages correctly
- [ ] Status badges show correct colors
- [ ] Confirmation dialog confirms/cancels actions
- [ ] Toast notifications appear and dismiss
---
## 🔗 Related Documents
- [ADR-012: UI Component Library](../../05-decisions/ADR-012-ui-component-library.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,408 @@
# TASK-FE-006: RFA Management UI
**ID:** TASK-FE-006
**Title:** RFA List, Create, View & Workflow UI
**Category:** Business Modules
**Priority:** P1 (High)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-003, TASK-FE-005, TASK-BE-007
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build comprehensive UI for Request for Approval (RFA) management including list with filters, create/edit forms with items, detail view, and approval workflow.
---
## 🎯 Objectives
1. Create RFA list with status filtering
2. Implement RFA creation form with multiple items
3. Build detail view showing items and approval history
4. Add approval workflow UI (Approve/Reject with comments)
5. Implement revision management
6. Add response tracking
---
## ✅ Acceptance Criteria
- [ ] List displays RFAs with pagination and filters
- [ ] Create form allows adding multiple RFA items
- [ ] Detail view shows items, attachments, and workflow history
- [ ] Approve/Reject dialog with comments functional
- [ ] Revision history visible
- [ ] Response tracking works (Approved/Rejected/Approved with Comments)
---
## 🔧 Implementation Steps
### Step 1: RFA List Page
```typescript
// File: src/app/(dashboard)/rfas/page.tsx
import { RFAList } from '@/components/rfas/list';
import { RFAFilters } from '@/components/rfas/filters';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { Plus } from 'lucide-react';
export default async function RFAsPage({
searchParams,
}: {
searchParams: { page?: string; status?: string };
}) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">RFAs (Request for Approval)</h1>
<p className="text-gray-600 mt-1">
Manage approval requests and submissions
</p>
</div>
<Link href="/rfas/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New RFA
</Button>
</Link>
</div>
<RFAFilters />
<RFAList />
</div>
);
}
```
### Step 2: RFA Form with Items
```typescript
// File: src/components/rfas/form.tsx
'use client';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import { Plus, Trash2 } from 'lucide-react';
const rfaItemSchema = z.object({
item_no: z.string(),
description: z.string().min(5),
quantity: z.number().min(0),
unit: z.string(),
drawing_reference: z.string().optional(),
});
const rfaSchema = z.object({
subject: z.string().min(5),
description: z.string().optional(),
contract_id: z.number(),
discipline_id: z.number(),
items: z.array(rfaItemSchema).min(1, 'At least one item required'),
});
type RFAFormData = z.infer<typeof rfaSchema>;
export function RFAForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<RFAFormData>({
resolver: zodResolver(rfaSchema),
defaultValues: {
items: [{ item_no: '1', description: '', quantity: 0, unit: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
const onSubmit = async (data: RFAFormData) => {
console.log(data);
// Submit to API
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl space-y-6">
{/* Basic Info */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">RFA Information</h3>
<div className="space-y-4">
<div>
<Label>Subject *</Label>
<Input {...register('subject')} />
{errors.subject && (
<p className="text-sm text-red-600 mt-1">
{errors.subject.message}
</p>
)}
</div>
<div>
<Label>Description</Label>
<Input {...register('description')} />
</div>
</div>
</Card>
{/* RFA Items */}
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">RFA Items</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
item_no: (fields.length + 1).toString(),
description: '',
quantity: 0,
unit: '',
})
}
>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
<div className="space-y-4">
{fields.map((field, index) => (
<Card key={field.id} className="p-4 bg-gray-50">
<div className="flex justify-between items-start mb-3">
<h4 className="font-medium">Item #{index + 1}</h4>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
)}
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label>Item No.</Label>
<Input {...register(`items.${index}.item_no`)} />
</div>
<div className="col-span-2">
<Label>Description *</Label>
<Input {...register(`items.${index}.description`)} />
</div>
<div>
<Label>Quantity</Label>
<Input
type="number"
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
})}
/>
</div>
</div>
</Card>
))}
</div>
{errors.items?.root && (
<p className="text-sm text-red-600 mt-2">
{errors.items.root.message}
</p>
)}
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Create RFA</Button>
</div>
</form>
);
}
```
### Step 3: RFA Detail with Approval Actions
```typescript
// File: src/components/rfas/detail.tsx
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { CheckCircle, XCircle } from 'lucide-react';
export function RFADetail({ data }: { data: any }) {
const [approvalDialog, setApprovalDialog] = useState<
'approve' | 'reject' | null
>(null);
const [comments, setComments] = useState('');
const handleApproval = async (action: 'approve' | 'reject') => {
// Call API
console.log({ action, comments });
setApprovalDialog(null);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold">{data.subject}</h1>
<div className="flex gap-3 mt-2">
<Badge>{data.status}</Badge>
<span className="text-gray-600">RFA No: {data.rfa_number}</span>
</div>
</div>
{data.status === 'PENDING' && (
<div className="flex gap-2">
<Button
variant="outline"
className="text-green-600"
onClick={() => setApprovalDialog('approve')}
>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</Button>
<Button
variant="outline"
className="text-red-600"
onClick={() => setApprovalDialog('reject')}
>
<XCircle className="mr-2 h-4 w-4" />
Reject
</Button>
</div>
)}
</div>
{/* RFA Items */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">RFA Items</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left">Item No.</th>
<th className="px-4 py-2 text-left">Description</th>
<th className="px-4 py-2 text-right">Quantity</th>
<th className="px-4 py-2 text-left">Unit</th>
<th className="px-4 py-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{data.items?.map((item: any) => (
<tr key={item.rfa_item_id} className="border-t">
<td className="px-4 py-3">{item.item_no}</td>
<td className="px-4 py-3">{item.description}</td>
<td className="px-4 py-3 text-right">{item.quantity}</td>
<td className="px-4 py-3">{item.unit}</td>
<td className="px-4 py-3">
<Badge
variant={
item.status === 'APPROVED' ? 'success' : 'default'
}
>
{item.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Approval Dialog */}
<Dialog
open={approvalDialog !== null}
onOpenChange={() => setApprovalDialog(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{approvalDialog === 'approve' ? 'Approve RFA' : 'Reject RFA'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Comments</Label>
<Textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
rows={4}
placeholder="Enter your comments..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setApprovalDialog(null)}>
Cancel
</Button>
<Button
onClick={() => handleApproval(approvalDialog!)}
variant={
approvalDialog === 'approve' ? 'default' : 'destructive'
}
>
{approvalDialog === 'approve' ? 'Approve' : 'Reject'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
```
---
## 📦 Deliverables
- [ ] RFA list page with filters
- [ ] Create/Edit form with dynamic items
- [ ] Detail view with items table
- [ ] Approval workflow UI (Approve/Reject)
- [ ] Revision management
- [ ] Response tracking
---
## 🔗 Related Documents
- [TASK-BE-007: RFA Module](./TASK-BE-007-rfa-module.md)
- [ADR-013: Form Handling](../../05-decisions/ADR-013-form-handling-validation.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,388 @@
# TASK-FE-007: Drawing Management UI
**ID:** TASK-FE-007
**Title:** Drawing List, Upload & Revision Management UI
**Category:** Business Modules
**Priority:** P2 (Medium)
**Effort:** 4-6 days
**Dependencies:** TASK-FE-003, TASK-FE-005, TASK-BE-008
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build UI for Drawing Management including Contract Drawings and Shop Drawings with revision tracking, file preview, and comparison features.
---
## 🎯 Objectives
1. Create drawing list with category filtering (Contract/Shop)
2. Implement drawing upload with metadata
3. Build revision management UI
4. Add file preview/download functionality
5. Implement drawing comparison (side-by-side)
6. Add version history view
---
## ✅ Acceptance Criteria
- [ ] List displays drawings grouped by type
- [ ] Upload form accepts drawing files (PDF, DWG)
- [ ] Revision history visible with compare feature
- [ ] File preview works for PDF
- [ ] Download functionality working
- [ ] Metadata (discipline, sheet number) editable
---
## 🔧 Implementation Steps
### Step 1: Drawing List with Category Tabs
```typescript
// File: src/app/(dashboard)/drawings/page.tsx
'use client';
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DrawingList } from '@/components/drawings/list';
import { Button } from '@/components/ui/button';
import { Upload } from 'lucide-react';
import Link from 'next/link';
export default function DrawingsPage() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Drawings</h1>
<p className="text-gray-600 mt-1">
Manage contract and shop drawings
</p>
</div>
<Link href="/drawings/upload">
<Button>
<Upload className="mr-2 h-4 w-4" />
Upload Drawing
</Button>
</Link>
</div>
<Tabs defaultValue="contract">
<TabsList>
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
</TabsList>
<TabsContent value="contract">
<DrawingList type="CONTRACT" />
</TabsContent>
<TabsContent value="shop">
<DrawingList type="SHOP" />
</TabsContent>
</Tabs>
</div>
);
}
```
### Step 2: Drawing Card with Preview
```typescript
// File: src/components/drawings/card.tsx
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { FileText, Download, Eye, GitCompare } from 'lucide-react';
import Link from 'next/link';
export function DrawingCard({ drawing }: { drawing: any }) {
return (
<Card className="p-6 hover:shadow-md transition-shadow">
<div className="flex gap-4">
{/* Thumbnail */}
<div className="w-32 h-32 bg-gray-100 rounded flex items-center justify-center">
<FileText className="h-16 w-16 text-gray-400" />
</div>
{/* Info */}
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-lg font-semibold">
{drawing.drawing_number}
</h3>
<p className="text-sm text-gray-600">{drawing.title}</p>
</div>
<Badge>{drawing.discipline?.discipline_code}</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-3">
<div>
<strong>Sheet:</strong> {drawing.sheet_number}
</div>
<div>
<strong>Revision:</strong> {drawing.current_revision}
</div>
<div>
<strong>Scale:</strong> {drawing.scale || 'N/A'}
</div>
<div>
<strong>Date:</strong>{' '}
{new Date(drawing.issue_date).toLocaleDateString()}
</div>
</div>
<div className="flex gap-2">
<Link href={`/drawings/${drawing.drawing_id}`}>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View
</Button>
</Link>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Download
</Button>
{drawing.revision_count > 1 && (
<Button variant="outline" size="sm">
<GitCompare className="mr-2 h-4 w-4" />
Compare
</Button>
)}
</div>
</div>
</div>
</Card>
);
}
```
### Step 3: Drawing Upload Form
```typescript
// File: src/app/(dashboard)/drawings/upload/page.tsx
import { DrawingUploadForm } from '@/components/drawings/upload-form';
export default function DrawingUploadPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-6">Upload Drawing</h1>
<DrawingUploadForm />
</div>
);
}
```
```typescript
// File: src/components/drawings/upload-form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card } from '@/components/ui/card';
const drawingSchema = z.object({
drawing_type: z.enum(['CONTRACT', 'SHOP']),
drawing_number: z.string().min(1),
title: z.string().min(5),
discipline_id: z.number(),
sheet_number: z.string(),
scale: z.string().optional(),
file: z.instanceof(File),
});
type DrawingFormData = z.infer<typeof drawingSchema>;
export function DrawingUploadForm() {
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<DrawingFormData>({
resolver: zodResolver(drawingSchema),
});
const onSubmit = async (data: DrawingFormData) => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value);
});
// Upload to API
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
<div className="space-y-4">
<div>
<Label>Drawing Type *</Label>
<Select onValueChange={(v) => setValue('drawing_type', v as any)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CONTRACT">Contract Drawing</SelectItem>
<SelectItem value="SHOP">Shop Drawing</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Drawing Number *</Label>
<Input {...register('drawing_number')} />
</div>
<div>
<Label>Sheet Number</Label>
<Input {...register('sheet_number')} />
</div>
</div>
<div>
<Label>Title *</Label>
<Input {...register('title')} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Discipline</Label>
<Select
onValueChange={(v) => setValue('discipline_id', parseInt(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">STR - Structure</SelectItem>
<SelectItem value="2">ARC - Architecture</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Scale</Label>
<Input {...register('scale')} placeholder="1:100" />
</div>
</div>
<div>
<Label>Drawing File *</Label>
<Input
type="file"
accept=".pdf,.dwg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setValue('file', file);
}}
/>
<p className="text-xs text-gray-500 mt-1">
Accepted: PDF, DWG (Max 50MB)
</p>
</div>
</div>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Upload Drawing</Button>
</div>
</form>
);
}
```
### Step 4: Revision History
```typescript
// File: src/components/drawings/revision-history.tsx
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
export function RevisionHistory({ revisions }: { revisions: any[] }) {
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Revision History</h3>
<div className="space-y-3">
{revisions.map((rev) => (
<div
key={rev.revision_id}
className="flex items-center justify-between p-3 bg-gray-50 rounded"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<Badge variant={rev.is_current ? 'default' : 'outline'}>
Rev. {rev.revision_number}
</Badge>
{rev.is_current && (
<span className="text-xs text-green-600 font-medium">
CURRENT
</span>
)}
</div>
<p className="text-sm text-gray-600">
{rev.revision_description}
</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(rev.revision_date).toLocaleDateString()} by{' '}
{rev.revised_by_name}
</p>
</div>
<Button variant="outline" size="sm">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
</Card>
);
}
```
---
## 📦 Deliverables
- [ ] Drawing list with Contract/Shop tabs
- [ ] Upload form with file validation
- [ ] Drawing cards with preview
- [ ] Revision history view
- [ ] File download functionality
- [ ] Comparison feature (optional)
---
## 🔗 Related Documents
- [TASK-BE-008: Drawing Module](./TASK-BE-008-drawing-module.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,382 @@
# TASK-FE-008: Search & Global Filters UI
**ID:** TASK-FE-008
**Title:** Global Search, Advanced Filters & Results UI
**Category:** Supporting Features
**Priority:** P2 (Medium)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-003, TASK-BE-010
**Assigned To:** Frontend Developer
---
## 📋 Overview
Implement global search functionality with advanced filters, faceted search, and unified results display across all document types.
---
## 🎯 Objectives
1. Create global search bar in header
2. Build advanced search page with filters
3. Implement faceted search (by type, status, date)
4. Create unified results display
5. Add search suggestions/autocomplete
6. Implement search history
---
## ✅ Acceptance Criteria
- [ ] Global search accessible from header
- [ ] Advanced filters work (type, status, date range, organization)
- [ ] Results show across all document types
- [ ] Search suggestions appear as user types
- [ ] Search history saved locally
- [ ] Results paginated with highlighting
---
## 🔧 Implementation Steps
### Step 1: Global Search Component in Header
```typescript
// File: src/components/layout/global-search.tsx
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { searchApi } from '@/lib/api/search';
export function GlobalSearch() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const debouncedQuery = useDebounce(query, 300);
// Fetch suggestions
useEffect(() => {
if (debouncedQuery.length > 2) {
searchApi.suggest(debouncedQuery).then(setSuggestions);
}
}, [debouncedQuery]);
const handleSearch = () => {
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query)}`);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search documents..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-9"
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="start">
<Command>
<CommandList>
{suggestions.length === 0 ? (
<CommandEmpty>No results found</CommandEmpty>
) : (
<CommandGroup heading="Suggestions">
{suggestions.map((item: any) => (
<CommandItem
key={item.id}
onSelect={() => {
setQuery(item.title);
router.push(`/${item.type}s/${item.id}`);
setOpen(false);
}}
>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">{item.type}</span>
<span>{item.title}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
### Step 2: Advanced Search Page
```typescript
// File: src/app/(dashboard)/search/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { SearchFilters } from '@/components/search/filters';
import { SearchResults } from '@/components/search/results';
import { searchApi } from '@/lib/api/search';
export default function SearchPage() {
const searchParams = useSearchParams();
const query = searchParams.get('q') || '';
const [results, setResults] = useState([]);
const [filters, setFilters] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (query) {
setLoading(true);
searchApi
.search({ query, ...filters })
.then(setResults)
.finally(() => setLoading(false));
}
}, [query, filters]);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-gray-600 mt-1">
Found {results.length} results for "{query}"
</p>
</div>
<div className="grid grid-cols-4 gap-6">
<div className="col-span-1">
<SearchFilters onFilterChange={setFilters} />
</div>
<div className="col-span-3">
<SearchResults results={results} query={query} loading={loading} />
</div>
</div>
</div>
);
}
```
### Step 3: Search Filters Component
```typescript
// File: src/components/search/filters.tsx
'use client';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
export function SearchFilters({
onFilterChange,
}: {
onFilterChange: (filters: any) => void;
}) {
const [filters, setFilters] = useState({
types: [],
statuses: [],
dateFrom: null,
dateTo: null,
});
const handleFilterChange = (key: string, value: any) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
onFilterChange(newFilters);
};
return (
<Card className="p-4 space-y-6">
<div>
<h3 className="font-semibold mb-3">Document Type</h3>
<div className="space-y-2">
{['Correspondence', 'RFA', 'Drawing', 'Transmittal'].map((type) => (
<label key={type} className="flex items-center gap-2">
<Checkbox
checked={filters.types.includes(type)}
onCheckedChange={(checked) => {
const newTypes = checked
? [...filters.types, type]
: filters.types.filter((t) => t !== type);
handleFilterChange('types', newTypes);
}}
/>
<span className="text-sm">{type}</span>
</label>
))}
</div>
</div>
<div>
<h3 className="font-semibold mb-3">Status</h3>
<div className="space-y-2">
{['Draft', 'Pending', 'Approved', 'Rejected'].map((status) => (
<label key={status} className="flex items-center gap-2">
<Checkbox />
<span className="text-sm">{status}</span>
</label>
))}
</div>
</div>
<div>
<h3 className="font-semibold mb-3">Date Range</h3>
<div className="space-y-2">
<div>
<Label className="text-xs">From</Label>
<Calendar mode="single" />
</div>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
setFilters({ types: [], statuses: [], dateFrom: null, dateTo: null });
onFilterChange({});
}}
>
Clear Filters
</Button>
</Card>
);
}
```
### Step 4: Search Results Component
```typescript
// File: src/components/search/results.tsx
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import { FileText, Clipboard, Image } from 'lucide-react';
export function SearchResults({ results, query, loading }: any) {
if (loading) {
return <div>Loading...</div>;
}
if (results.length === 0) {
return (
<Card className="p-12 text-center text-gray-500">
No results found for "{query}"
</Card>
);
}
const getIcon = (type: string) => {
switch (type) {
case 'correspondence':
return FileText;
case 'rfa':
return Clipboard;
case 'drawing':
return Image;
default:
return FileText;
}
};
return (
<div className="space-y-4">
{results.map((result: any) => {
const Icon = getIcon(result.type);
return (
<Card
key={result.id}
className="p-6 hover:shadow-md transition-shadow"
>
<Link href={`/${result.type}s/${result.id}`}>
<div className="flex gap-4">
<div className="flex-shrink-0">
<Icon className="h-6 w-6 text-gray-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold hover:text-primary">
{result.title}
</h3>
<Badge>{result.type}</Badge>
<Badge variant="outline">{result.status}</Badge>
</div>
<p className="text-sm text-gray-600 mb-2">
{result.highlight || result.description}
</p>
<div className="flex gap-4 text-xs text-gray-500">
<span>{result.documentNumber}</span>
<span></span>
<span>
{new Date(result.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</Link>
</Card>
);
})}
</div>
);
}
```
---
## 📦 Deliverables
- [ ] Global search component in header
- [ ] Advanced search page
- [ ] Filters panel (type, status, date)
- [ ] Results display with highlighting
- [ ] Search suggestions/autocomplete
- [ ] Mobile responsive design
---
## 🔗 Related Documents
- [TASK-BE-010: Search & Elasticsearch](./TASK-BE-010-search-elasticsearch.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,344 @@
# TASK-FE-009: Dashboard & Notifications UI
**ID:** TASK-FE-009
**Title:** Dashboard, Notifications & Activity Feed UI
**Category:** Supporting Features
**Priority:** P3 (Low)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-003, TASK-BE-011
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build dashboard homepage with statistics widgets, recent activity, pending approvals, and real-time notifications system.
---
## 🎯 Objectives
1. Create dashboard homepage with widgets
2. Implement statistics cards (documents, pending approvals)
3. Build recent activity feed
4. Create notifications dropdown
5. Add pending tasks section
6. Implement real-time updates (optional)
---
## ✅ Acceptance Criteria
- [ ] Dashboard displays key statistics
- [ ] Recent activity feed working
- [ ] Notifications dropdown functional
- [ ] Pending tasks visible
- [ ] Charts/graphs display data
- [ ] Real-time updates (if WebSocket implemented)
---
## 🔧 Implementation Steps
### Step 1: Dashboard Page
```typescript
// File: src/app/(dashboard)/page.tsx
import { StatsCards } from '@/components/dashboard/stats-cards';
import { RecentActivity } from '@/components/dashboard/recent-activity';
import { PendingTasks } from '@/components/dashboard/pending-tasks';
import { QuickActions } from '@/components/dashboard/quick-actions';
export default async function DashboardPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-gray-600 mt-1">
Welcome back! Here's what's happening.
</p>
</div>
<QuickActions />
<StatsCards />
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
<RecentActivity />
</div>
<div className="col-span-1">
<PendingTasks />
</div>
</div>
</div>
);
}
```
### Step 2: Statistics Cards
```typescript
// File: src/components/dashboard/stats-cards.tsx
import { Card } from '@/components/ui/card';
import { FileText, Clipboard, CheckCircle, Clock } from 'lucide-react';
export async function StatsCards() {
const stats = await getStats(); // Fetch from API
const cards = [
{
title: 'Total Correspondences',
value: stats.correspondences,
icon: FileText,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
{
title: 'Active RFAs',
value: stats.rfas,
icon: Clipboard,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
},
{
title: 'Approved Documents',
value: stats.approved,
icon: CheckCircle,
color: 'text-green-600',
bgColor: 'bg-green-50',
},
{
title: 'Pending Approvals',
value: stats.pending,
icon: Clock,
color: 'text-orange-600',
bgColor: 'bg-orange-50',
},
];
return (
<div className="grid grid-cols-4 gap-6">
{cards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.title} className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{card.title}</p>
<p className="text-3xl font-bold mt-2">{card.value}</p>
</div>
<div className={`p-3 rounded-lg ${card.bgColor}`}>
<Icon className={`h-6 w-6 ${card.color}`} />
</div>
</div>
</Card>
);
})}
</div>
);
}
```
### Step 3: Recent Activity Feed
```typescript
// File: src/components/dashboard/recent-activity.tsx
import { Card } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
export async function RecentActivity() {
const activities = await getRecentActivities();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
<div className="space-y-4">
{activities.map((activity) => (
<div
key={activity.id}
className="flex gap-3 pb-4 border-b last:border-0"
>
<Avatar className="h-10 w-10">
<AvatarFallback>{activity.user.initials}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{activity.user.name}</span>
<Badge variant="outline" className="text-xs">
{activity.action}
</Badge>
</div>
<p className="text-sm text-gray-600">{activity.description}</p>
<p className="text-xs text-gray-500 mt-1">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
})}
</p>
</div>
</div>
))}
</div>
</Card>
);
}
```
### Step 4: Notifications Dropdown
```typescript
// File: src/components/layout/notifications-dropdown.tsx
'use client';
import { useState, useEffect } from 'react';
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { notificationApi } from '@/lib/api/notifications';
export function NotificationsDropdown() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
// Fetch notifications
notificationApi.getUnread().then((data) => {
setNotifications(data.items);
setUnreadCount(data.unreadCount);
});
}, []);
const markAsRead = async (id: number) => {
await notificationApi.markAsRead(id);
setNotifications((prev) => prev.filter((n) => n.notification_id !== id));
setUnreadCount((prev) => prev - 1);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
{unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
No new notifications
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
<DropdownMenuItem
key={notification.notification_id}
className="flex flex-col items-start p-3 cursor-pointer"
onClick={() => markAsRead(notification.notification_id)}
>
<div className="font-medium text-sm">{notification.title}</div>
<div className="text-xs text-gray-600 mt-1">
{notification.message}
</div>
<div className="text-xs text-gray-400 mt-1">
{formatDistanceToNow(new Date(notification.created_at), {
addSuffix: true,
})}
</div>
</DropdownMenuItem>
))}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-center justify-center">
View All
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
```
### Step 5: Pending Tasks Widget
```typescript
// File: src/components/dashboard/pending-tasks.tsx
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
export async function PendingTasks() {
const tasks = await getPendingTasks();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Pending Tasks</h3>
<div className="space-y-3">
{tasks.map((task) => (
<Link
key={task.id}
href={task.url}
className="block p-3 bg-gray-50 rounded hover:bg-gray-100 transition-colors"
>
<div className="flex items-start justify-between mb-1">
<span className="text-sm font-medium">{task.title}</span>
<Badge variant="warning" className="text-xs">
{task.daysOverdue > 0 ? `${task.daysOverdue}d overdue` : 'Due'}
</Badge>
</div>
<p className="text-xs text-gray-600">{task.description}</p>
</Link>
))}
</div>
</Card>
);
}
```
---
## 📦 Deliverables
- [ ] Dashboard page with widgets
- [ ] Statistics cards
- [ ] Recent activity feed
- [ ] Notifications dropdown
- [ ] Pending tasks section
- [ ] Quick actions buttons
---
## 🔗 Related Documents
- [TASK-BE-011: Notification & Audit](./TASK-BE-011-notification-audit.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,680 @@
# TASK-FE-010: Admin Panel & Settings UI
**ID:** TASK-FE-010
**Title:** Admin Panel for User & Master Data Management
**Category:** Administration
**Priority:** P2 (Medium)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-002, TASK-FE-005, TASK-BE-012, TASK-BE-013
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build comprehensive Admin Panel for managing users, roles, master data (organizations, projects, contracts, disciplines, document types), system settings, and viewing audit logs.
---
## 🎯 Objectives
1. Create admin layout with separate navigation
2. Build User Management UI (CRUD users, assign roles)
3. Implement Master Data Management screens
4. Create System Settings interface
5. Build Audit Logs viewer
6. Add bulk operations and data import/export
---
## ✅ Acceptance Criteria
- [ ] Admin area accessible only to admins
- [ ] User management (create/edit/delete/deactivate)
- [ ] Role assignment with permission preview
- [ ] Master data CRUD (Organizations, Projects, etc.)
- [ ] Audit logs searchable and filterable
- [ ] System settings editable
- [ ] CSV import/export for bulk operations
---
## 🔧 Implementation Steps
### Step 1: Admin Layout
```typescript
// File: src/app/(admin)/layout.tsx
import { AdminSidebar } from '@/components/admin/sidebar';
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession();
// Check if user has admin role
if (!session?.user?.roles?.some((r) => r.role_name === 'ADMIN')) {
redirect('/');
}
return (
<div className="flex h-screen">
<AdminSidebar />
<div className="flex-1 overflow-auto">{children}</div>
</div>
);
}
```
### Step 2: User Management Page
```typescript
// File: src/app/(admin)/admin/users/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { UserDialog } from '@/components/admin/user-dialog';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Plus } from 'lucide-react';
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const columns: ColumnDef<any>[] = [
{
accessorKey: 'username',
header: 'Username',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'first_name',
header: 'Name',
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.is_active ? 'success' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
),
},
{
id: 'roles',
header: 'Roles',
cell: ({ row }) => (
<div className="flex gap-1">
{row.original.roles?.map((role: any) => (
<Badge key={role.user_role_id} variant="outline">
{role.role_name}
</Badge>
))}
</div>
),
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedUser(row.original);
setDialogOpen(true);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeactivate(row.original.user_id)}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-gray-600 mt-1">
Manage system users and their roles
</p>
</div>
<Button
onClick={() => {
setSelectedUser(null);
setDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
</div>
<DataTable columns={columns} data={users} />
<UserDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
user={selectedUser}
/>
</div>
);
}
```
### Step 3: User Create/Edit Dialog
```typescript
// File: src/components/admin/user-dialog.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
const userSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
first_name: z.string().min(1),
last_name: z.string().min(1),
password: z.string().min(6).optional(),
is_active: z.boolean().default(true),
roles: z.array(z.number()),
});
type UserFormData = z.infer<typeof userSchema>;
interface UserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: any;
}
export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: user || {},
});
const availableRoles = [
{ role_id: 1, role_name: 'ADMIN', description: 'System Administrator' },
{ role_id: 2, role_name: 'USER', description: 'Regular User' },
{ role_id: 3, role_name: 'APPROVER', description: 'Document Approver' },
];
const selectedRoles = watch('roles') || [];
const onSubmit = async (data: UserFormData) => {
// Call API to create/update user
console.log(data);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{user ? 'Edit User' : 'Create New User'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Username *</Label>
<Input {...register('username')} />
{errors.username && (
<p className="text-sm text-red-600 mt-1">
{errors.username.message}
</p>
)}
</div>
<div>
<Label>Email *</Label>
<Input type="email" {...register('email')} />
{errors.email && (
<p className="text-sm text-red-600 mt-1">
{errors.email.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>First Name *</Label>
<Input {...register('first_name')} />
</div>
<div>
<Label>Last Name *</Label>
<Input {...register('last_name')} />
</div>
</div>
{!user && (
<div>
<Label>Password *</Label>
<Input type="password" {...register('password')} />
{errors.password && (
<p className="text-sm text-red-600 mt-1">
{errors.password.message}
</p>
)}
</div>
)}
<div>
<Label className="mb-3 block">Roles</Label>
<div className="space-y-2">
{availableRoles.map((role) => (
<label
key={role.role_id}
className="flex items-start gap-3 p-3 border rounded hover:bg-gray-50"
>
<Checkbox
checked={selectedRoles.includes(role.role_id)}
onCheckedChange={(checked) => {
const newRoles = checked
? [...selectedRoles, role.role_id]
: selectedRoles.filter((id) => id !== role.role_id);
setValue('roles', newRoles);
}}
/>
<div>
<div className="font-medium">{role.role_name}</div>
<div className="text-sm text-gray-600">
{role.description}
</div>
</div>
</label>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox {...register('is_active')} defaultChecked />
<Label>Active</Label>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">
{user ? 'Update User' : 'Create User'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
```
### Step 4: Master Data Management (Organizations)
```typescript
// File: src/app/(admin)/admin/organizations/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [formData, setFormData] = useState({
org_code: '',
org_name: '',
org_name_th: '',
description: '',
});
const columns = [
{ accessorKey: 'org_code', header: 'Code' },
{ accessorKey: 'org_name', header: 'Name (EN)' },
{ accessorKey: 'org_name_th', header: 'Name (TH)' },
{ accessorKey: 'description', header: 'Description' },
];
const handleSubmit = async () => {
// Call API to create organization
console.log(formData);
setDialogOpen(false);
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Organizations</h1>
<p className="text-gray-600 mt-1">Manage project organizations</p>
</div>
<Button onClick={() => setDialogOpen(true)}>Add Organization</Button>
</div>
<DataTable columns={columns} data={organizations} />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Organization</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Organization Code *</Label>
<Input
value={formData.org_code}
onChange={(e) =>
setFormData({ ...formData, org_code: e.target.value })
}
placeholder="e.g., กทท."
/>
</div>
<div>
<Label>Name (English) *</Label>
<Input
value={formData.org_name}
onChange={(e) =>
setFormData({ ...formData, org_name: e.target.value })
}
/>
</div>
<div>
<Label>Name (Thai)</Label>
<Input
value={formData.org_name_th}
onChange={(e) =>
setFormData({ ...formData, org_name_th: e.target.value })
}
/>
</div>
<div>
<Label>Description</Label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
```
### Step 5: Audit Logs Viewer
```typescript
// File: src/app/(admin)/admin/audit-logs/page.tsx
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
export default function AuditLogsPage() {
const [logs, setLogs] = useState([]);
const [filters, setFilters] = useState({
user: '',
action: '',
entity: '',
});
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">Audit Logs</h1>
<p className="text-gray-600 mt-1">View system activity and changes</p>
</div>
{/* Filters */}
<Card className="p-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Input placeholder="Search user..." />
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CREATE">Create</SelectItem>
<SelectItem value="UPDATE">Update</SelectItem>
<SelectItem value="DELETE">Delete</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Entity Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
{/* Logs List */}
<div className="space-y-2">
{logs.map((log: any) => (
<Card key={log.audit_log_id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{log.user_name}</span>
<Badge>{log.action}</Badge>
<Badge variant="outline">{log.entity_type}</Badge>
</div>
<p className="text-sm text-gray-600">{log.description}</p>
<p className="text-xs text-gray-500 mt-2">
{formatDistanceToNow(new Date(log.created_at), {
addSuffix: true,
})}
</p>
</div>
{log.ip_address && (
<span className="text-xs text-gray-500">
IP: {log.ip_address}
</span>
)}
</div>
</Card>
))}
</div>
</div>
);
}
```
### Step 6: Admin Sidebar Navigation
```typescript
// File: src/components/admin/sidebar.tsx
'use client';
import Link from 'link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Users, Building2, Settings, FileText, Activity } from 'lucide-react';
const menuItems = [
{ href: '/admin/users', label: 'Users', icon: Users },
{ href: '/admin/organizations', label: 'Organizations', icon: Building2 },
{ href: '/admin/projects', label: 'Projects', icon: FileText },
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
];
export function AdminSidebar() {
const pathname = usePathname();
return (
<aside className="w-64 border-r bg-gray-50 p-4">
<h2 className="text-lg font-bold mb-6">Admin Panel</h2>
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-gray-100'
)}
>
<Icon className="h-5 w-5" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}
```
---
## 📦 Deliverables
- [ ] Admin layout with sidebar navigation
- [ ] User Management (CRUD, roles assignment)
- [ ] Master Data Management screens:
- [ ] Organizations
- [ ] Projects
- [ ] Contracts
- [ ] Disciplines
- [ ] Document Types
- [ ] System Settings interface
- [ ] Audit Logs viewer with filters
- [ ] CSV import/export functionality
---
## 🧪 Testing
### Test Cases
1. **User Management**
- Create new user
- Assign multiple roles
- Deactivate/activate user
- Delete user
2. **Master Data**
- Create organization
- Edit organization details
- Delete organization (check for dependencies)
3. **Audit Logs**
- View all logs
- Filter by user/action/entity
- Search logs
- Export logs
---
## 🔗 Related Documents
- [TASK-BE-012: Master Data Management](./TASK-BE-012-master-data-management.md)
- [TASK-BE-013: User Management](./TASK-BE-013-user-management.md)
- [ADR-004: RBAC Implementation](../../05-decisions/ADR-004-rbac-implementation.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,505 @@
# TASK-FE-011: Workflow Configuration UI
**ID:** TASK-FE-011
**Title:** Workflow DSL Builder & Configuration UI
**Category:** Administration
**Priority:** P2 (Medium)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-010, TASK-BE-006
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build UI for configuring and managing workflows using the DSL-based workflow engine, including visual workflow builder, DSL editor, and workflow testing interface.
---
## 🎯 Objectives
1. Create workflow list and management interface
2. Build DSL editor with syntax highlighting
3. Implement visual workflow builder (drag-and-drop)
4. Add workflow validation and testing tools
5. Create workflow template library
6. Implement workflow versioning UI
---
## ✅ Acceptance Criteria
- [ ] List all workflows with status
- [ ] Create/edit workflows with DSL editor
- [ ] Visual workflow builder functional
- [ ] DSL validation shows errors
- [ ] Test workflow with sample data
- [ ] Workflow templates available
- [ ] Version history viewable
---
## 🔧 Implementation Steps
### Step 1: Workflow List Page
```typescript
// File: src/app/(admin)/admin/workflows/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Copy, Trash } from 'lucide-react';
import Link from 'next/link';
export default function WorkflowsPage() {
const [workflows, setWorkflows] = useState([]);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Workflow Configuration</h1>
<p className="text-gray-600 mt-1">
Manage workflow definitions and routing rules
</p>
</div>
<Link href="/admin/workflows/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Workflow
</Button>
</Link>
</div>
<div className="grid gap-4">
{workflows.map((workflow: any) => (
<Card key={workflow.workflow_id} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{workflow.workflow_name}
</h3>
<Badge variant={workflow.is_active ? 'success' : 'secondary'}>
{workflow.is_active ? 'Active' : 'Inactive'}
</Badge>
<Badge variant="outline">v{workflow.version}</Badge>
</div>
<p className="text-sm text-gray-600 mb-3">
{workflow.description}
</p>
<div className="flex gap-6 text-sm text-gray-500">
<span>Type: {workflow.workflow_type}</span>
<span>Steps: {workflow.step_count}</span>
<span>
Updated:{' '}
{new Date(workflow.updated_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/workflows/${workflow.workflow_id}/edit`}>
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
<Button variant="outline" size="sm">
<Copy className="mr-2 h-4 w-4" />
Clone
</Button>
<Button variant="outline" size="sm" className="text-red-600">
<Trash className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
```
### Step 2: DSL Editor Component
```typescript
// File: src/components/workflows/dsl-editor.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle, AlertCircle, Play } from 'lucide-react';
import Editor from '@monaco-editor/react';
interface DSLEditorProps {
initialValue?: string;
onChange?: (value: string) => void;
}
export function DSLEditor({ initialValue = '', onChange }: DSLEditorProps) {
const [dsl, setDsl] = useState(initialValue);
const [validationResult, setValidationResult] = useState<any>(null);
const [isValidating, setIsValidating] = useState(false);
const handleEditorChange = (value: string | undefined) => {
const newValue = value || '';
setDsl(newValue);
onChange?.(newValue);
setValidationResult(null); // Clear validation on change
};
const validateDSL = async () => {
setIsValidating(true);
try {
const response = await fetch('/api/workflows/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dsl }),
});
const result = await response.json();
setValidationResult(result);
} catch (error) {
setValidationResult({ valid: false, errors: ['Validation failed'] });
} finally {
setIsValidating(false);
}
};
const testWorkflow = async () => {
// Open test dialog
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Workflow DSL</h3>
<div className="flex gap-2">
<Button
variant="outline"
onClick={validateDSL}
disabled={isValidating}
>
<CheckCircle className="mr-2 h-4 w-4" />
Validate
</Button>
<Button variant="outline" onClick={testWorkflow}>
<Play className="mr-2 h-4 w-4" />
Test
</Button>
</div>
</div>
<Card className="overflow-hidden">
<Editor
height="500px"
defaultLanguage="yaml"
value={dsl}
onChange={handleEditorChange}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
rulers: [80],
wordWrap: 'on',
}}
/>
</Card>
{validationResult && (
<Alert variant={validationResult.valid ? 'default' : 'destructive'}>
{validationResult.valid ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>
{validationResult.valid ? (
'DSL is valid ✓'
) : (
<div>
<p className="font-medium mb-2">Validation Errors:</p>
<ul className="list-disc list-inside space-y-1">
{validationResult.errors?.map((error: string, i: number) => (
<li key={i} className="text-sm">
{error}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
)}
</div>
);
}
```
### Step 3: Visual Workflow Builder
```typescript
// File: src/components/workflows/visual-builder.tsx
'use client';
import { useState, useCallback } from 'react';
import ReactFlow, {
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Connection,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
const nodeTypes = {
start: { color: '#10b981' },
step: { color: '#3b82f6' },
condition: { color: '#f59e0b' },
end: { color: '#ef4444' },
};
export function VisualWorkflowBuilder() {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
const addNode = (type: string) => {
const newNode: Node = {
id: `${type}-${Date.now()}`,
type: 'default',
position: { x: Math.random() * 400, y: Math.random() * 400 },
data: { label: `${type} Node` },
style: {
background: nodeTypes[type]?.color || '#gray',
color: 'white',
padding: 10,
},
};
setNodes((nds) => [...nds, newNode]);
};
const generateDSL = () => {
// Convert visual workflow to DSL
const dsl = {
name: 'Generated Workflow',
steps: nodes.map((node) => ({
step_name: node.data.label,
step_type: 'APPROVAL',
})),
};
return JSON.stringify(dsl, null, 2);
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<Button onClick={() => addNode('start')} variant="outline">
Add Start
</Button>
<Button onClick={() => addNode('step')} variant="outline">
Add Step
</Button>
<Button onClick={() => addNode('condition')} variant="outline">
Add Condition
</Button>
<Button onClick={() => addNode('end')} variant="outline">
Add End
</Button>
<Button onClick={generateDSL} className="ml-auto">
Generate DSL
</Button>
</div>
<Card className="h-[600px]">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Controls />
<Background />
</ReactFlow>
</Card>
</div>
);
}
```
### Step 4: Workflow Editor Page
```typescript
// File: src/app/(admin)/admin/workflows/[id]/edit/page.tsx
'use client';
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DSLEditor } from '@/components/workflows/dsl-editor';
import { VisualWorkflowBuilder } from '@/components/workflows/visual-builder';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card';
export default function WorkflowEditPage() {
const [workflowData, setWorkflowData] = useState({
workflow_name: '',
description: '',
workflow_type: 'CORRESPONDENCE',
dsl_definition: '',
});
const handleSave = async () => {
// Save workflow
console.log('Saving workflow:', workflowData);
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Edit Workflow</h1>
<div className="flex gap-2">
<Button variant="outline">Cancel</Button>
<Button onClick={handleSave}>Save Workflow</Button>
</div>
</div>
<Card className="p-6">
<div className="grid gap-4">
<div>
<Label>Workflow Name *</Label>
<Input
value={workflowData.workflow_name}
onChange={(e) =>
setWorkflowData({
...workflowData,
workflow_name: e.target.value,
})
}
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={workflowData.description}
onChange={(e) =>
setWorkflowData({
...workflowData,
description: e.target.value,
})
}
/>
</div>
<div>
<Label>Workflow Type</Label>
<Select
value={workflowData.workflow_type}
onValueChange={(value) =>
setWorkflowData({ ...workflowData, workflow_type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CORRESPONDENCE">Correspondence</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="DRAWING">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
<Tabs defaultValue="dsl">
<TabsList>
<TabsTrigger value="dsl">DSL Editor</TabsTrigger>
<TabsTrigger value="visual">Visual Builder</TabsTrigger>
</TabsList>
<TabsContent value="dsl">
<DSLEditor
initialValue={workflowData.dsl_definition}
onChange={(value) =>
setWorkflowData({ ...workflowData, dsl_definition: value })
}
/>
</TabsContent>
<TabsContent value="visual">
<VisualWorkflowBuilder />
</TabsContent>
</Tabs>
</div>
);
}
```
---
## 📦 Deliverables
- [ ] Workflow list page
- [ ] DSL editor with syntax highlighting
- [ ] DSL validation endpoint integration
- [ ] Visual workflow builder (ReactFlow)
- [ ] Workflow testing interface
- [ ] Template library
- [ ] Version history viewer
---
## 🧪 Testing
1. **DSL Editor**
- Write valid DSL → Validates successfully
- Write invalid DSL → Shows errors
- Save workflow → DSL persists
2. **Visual Builder**
- Add nodes → Nodes appear
- Connect nodes → Edges created
- Generate DSL → Valid DSL output
3. **Workflow Management**
- Create workflow → Saves to DB
- Edit workflow → Updates correctly
- Clone workflow → Creates copy
---
## 🔗 Related Documents
- [TASK-BE-006: Workflow Engine](./TASK-BE-006-workflow-engine.md)
- [ADR-001: Unified Workflow Engine](../../05-decisions/ADR-001-unified-workflow-engine.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,517 @@
# TASK-FE-012: Document Numbering Configuration UI
**ID:** TASK-FE-012
**Title:** Document Numbering Template Management UI
**Category:** Administration
**Priority:** P2 (Medium)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-010, TASK-BE-004
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build UI for configuring and managing document numbering templates including template builder, preview generator, and number sequence management.
---
## 🎯 Objectives
1. Create numbering template list and management
2. Build template editor with format preview
3. Implement template variable selector
4. Add numbering sequence viewer
5. Create template testing interface
6. Implement annual reset configuration
---
## ✅ Acceptance Criteria
- [ ] List all numbering templates by document type
- [ ] Create/edit templates with format preview
- [ ] Template variables easily selectable
- [ ] Preview shows example numbers
- [ ] View current number sequences
- [ ] Annual reset configurable
- [ ] Validation prevents conflicts
---
## 🔧 Implementation Steps
### Step 1: Template List Page
```typescript
// File: src/app/(admin)/admin/numbering/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Eye } from 'lucide-react';
export default function NumberingPage() {
const [templates, setTemplates] = useState([]);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">
Document Numbering Configuration
</h1>
<p className="text-gray-600 mt-1">
Manage document numbering templates and sequences
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
<div className="grid gap-4">
{templates.map((template: any) => (
<Card key={template.template_id} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.document_type_name}
</h3>
<Badge>{template.discipline_code || 'All'}</Badge>
<Badge variant={template.is_active ? 'success' : 'secondary'}>
{template.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="bg-gray-100 rounded px-3 py-2 mb-3 font-mono text-sm">
{template.template_format}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Example: </span>
<span className="font-medium">
{template.example_number}
</span>
</div>
<div>
<span className="text-gray-600">Current Sequence: </span>
<span className="font-medium">
{template.current_number}
</span>
</div>
<div>
<span className="text-gray-600">Annual Reset: </span>
<span className="font-medium">
{template.reset_annually ? 'Yes' : 'No'}
</span>
</div>
<div>
<span className="text-gray-600">Padding: </span>
<span className="font-medium">
{template.padding_length} digits
</span>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View Sequences
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
```
### Step 2: Template Editor Component
```typescript
// File: src/components/numbering/template-editor.tsx
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
const VARIABLES = [
{ key: '{ORG}', name: 'Organization Code', example: 'กทท' },
{ key: '{DOCTYPE}', name: 'Document Type', example: 'CORR' },
{ key: '{DISC}', name: 'Discipline', example: 'STR' },
{ key: '{YYYY}', name: 'Year (4-digit)', example: '2025' },
{ key: '{YY}', name: 'Year (2-digit)', example: '25' },
{ key: '{MM}', name: 'Month', example: '12' },
{ key: '{SEQ}', name: 'Sequence Number', example: '0001' },
{ key: '{CONTRACT}', name: 'Contract Code', example: 'C01' },
];
export function TemplateEditor({ template, onSave }: any) {
const [format, setFormat] = useState(template?.template_format || '');
const [preview, setPreview] = useState('');
useEffect(() => {
// Generate preview
let previewText = format;
VARIABLES.forEach((v) => {
previewText = previewText.replace(new RegExp(v.key, 'g'), v.example);
});
setPreview(previewText);
}, [format]);
const insertVariable = (variable: string) => {
setFormat((prev) => prev + variable);
};
return (
<Card className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Template Configuration</h3>
<div className="grid gap-4">
<div>
<Label>Document Type *</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="drawing">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="All disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
<SelectItem value="STR">STR - Structure</SelectItem>
<SelectItem value="ARC">ARC - Architecture</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Template Format *</Label>
<div className="space-y-2">
<Input
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="e.g., {ORG}-{DOCTYPE}-{YYYY}-{SEQ}"
className="font-mono"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
<Button
key={v.key}
variant="outline"
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
>
{v.key}
</Button>
))}
</div>
</div>
</div>
<div>
<Label>Preview</Label>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">Example number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{preview || 'Enter format above'}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Sequence Padding Length</Label>
<Input type="number" defaultValue={4} min={1} max={10} />
<p className="text-xs text-gray-500 mt-1">
Number of digits (e.g., 4 = 0001, 0002)
</p>
</div>
<div>
<Label>Starting Number</Label>
<Input type="number" defaultValue={1} min={1} />
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2">
<Checkbox defaultChecked />
<span className="text-sm">Reset annually (on January 1st)</span>
</label>
</div>
</div>
</div>
{/* Variable Reference */}
<div>
<h4 className="font-semibold mb-3">Available Variables</h4>
<div className="grid grid-cols-2 gap-3">
{VARIABLES.map((v) => (
<div
key={v.key}
className="flex items-center justify-between p-2 bg-gray-50 rounded"
>
<div>
<Badge variant="outline" className="font-mono">
{v.key}
</Badge>
<p className="text-xs text-gray-600 mt-1">{v.name}</p>
</div>
<span className="text-sm text-gray-500">{v.example}</span>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button onClick={onSave}>Save Template</Button>
</div>
</Card>
);
}
```
### Step 3: Number Sequence Viewer
```typescript
// File: src/components/numbering/sequence-viewer.tsx
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { RefreshCw } from 'lucide-react';
export function SequenceViewer({ templateId }: { templateId: number }) {
const [sequences, setSequences] = useState([]);
const [search, setSearch] = useState('');
return (
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Number Sequences</h3>
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
<div className="mb-4">
<Input
placeholder="Search by year, organization..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2">
{sequences.map((seq: any) => (
<div
key={seq.sequence_id}
className="flex items-center justify-between p-3 bg-gray-50 rounded"
>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{seq.year}</span>
{seq.organization_code && (
<Badge>{seq.organization_code}</Badge>
)}
{seq.discipline_code && (
<Badge variant="outline">{seq.discipline_code}</Badge>
)}
</div>
<div className="text-sm text-gray-600">
Current: {seq.current_number} | Last Generated:{' '}
{seq.last_generated_number}
</div>
</div>
<div className="text-sm text-gray-500">
Updated {new Date(seq.updated_at).toLocaleDateString()}
</div>
</div>
))}
</div>
</Card>
);
}
```
### Step 4: Template Testing Dialog
```typescript
// File: src/components/numbering/template-tester.tsx
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
export function TemplateTester({ open, onOpenChange, template }: any) {
const [testData, setTestData] = useState({
organization_id: 1,
discipline_id: null,
year: new Date().getFullYear(),
});
const [generatedNumber, setGeneratedNumber] = useState('');
const handleTest = async () => {
// Call API to generate test number
const response = await fetch('/api/numbering/test', {
method: 'POST',
body: JSON.stringify({ template_id: template.template_id, ...testData }),
});
const result = await response.json();
setGeneratedNumber(result.number);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Number Generation</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Organization</Label>
<Select value={testData.organization_id.toString()}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Select discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">STR</SelectItem>
<SelectItem value="2">ARC</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleTest} className="w-full">
Generate Test Number
</Button>
{generatedNumber && (
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-gray-600 mb-1">Generated Number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{generatedNumber}
</p>
</Card>
)}
</div>
</DialogContent>
</Dialog>
);
}
```
---
## 📦 Deliverables
- [ ] Template list page
- [ ] Template editor with variable selector
- [ ] Live preview generator
- [ ] Number sequence viewer
- [ ] Template testing interface
- [ ] Annual reset configuration
- [ ] Validation rules
---
## 🧪 Testing
1. **Template Creation**
- Create template → Preview updates
- Insert variables → Format correct
- Save template → Persists
2. **Number Generation**
- Test template → Generates number
- Variables replaced correctly
- Sequence increments
3. **Sequence Management**
- View sequences → Shows all active sequences
- Search sequences → Filters correctly
---
## 🔗 Related Documents
- [TASK-BE-004: Document Numbering](./TASK-BE-004-document-numbering.md)
- [ADR-002: Document Numbering Strategy](../../05-decisions/ADR-002-document-numbering-strategy.md)
---
**Created:** 2025-12-01
**Status:** Ready