Main: revise specs to 1.5.0 (completed)
This commit is contained in:
1
.github/workflows/link-checker.yml
vendored
Normal file
1
.github/workflows/link-checker.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# ตรวจสอบ broken links
|
||||
64
.github/workflows/spec-validation.yml
vendored
64
.github/workflows/spec-validation.yml
vendored
@@ -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
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -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",
|
||||
|
||||
667
CONTRIBUTING.md
667
CONTRIBUTING.md
@@ -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
510
README.md
@@ -1 +1,509 @@
|
||||
# Project documentation hub
|
||||
# 📋 LCBP3-DMS - Document Management System
|
||||
|
||||
> **Laem Chabang Port Phase 3 - Document Management System**
|
||||
>
|
||||
> ระบบบริหารจัดการเอกสารโครงการแบบครบวงจร สำหรับโครงการก่อสร้างท่าเรือแหลมฉบังระยะที่ 3
|
||||
|
||||
[](./CHANGELOG.md)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ภาพรวมโครงการ
|
||||
|
||||
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
714
docs/temp.md
Normal 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 และจะถูกปรับปรุงตามความต้องการของทีมพัฒนา**
|
||||
@@ -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": {
|
||||
|
||||
@@ -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! 🚀**
|
||||
|
||||
@@ -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
|
||||
|
||||
389
specs/00-overview/quick-start.md
Normal file
389
specs/00-overview/quick-start.md
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
190
specs/04-operations/README.md
Normal file
190
specs/04-operations/README.md
Normal 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
|
||||
374
specs/04-operations/backup-recovery.md
Normal file
374
specs/04-operations/backup-recovery.md
Normal 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
|
||||
0
specs/04-operations/deployment.md
Normal file
0
specs/04-operations/deployment.md
Normal file
0
specs/04-operations/disaster-recovery.md
Normal file
0
specs/04-operations/disaster-recovery.md
Normal file
463
specs/04-operations/environment-setup.md
Normal file
463
specs/04-operations/environment-setup.md
Normal 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
|
||||
483
specs/04-operations/incident-response.md
Normal file
483
specs/04-operations/incident-response.md
Normal 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
|
||||
501
specs/04-operations/maintenance-procedures.md
Normal file
501
specs/04-operations/maintenance-procedures.md
Normal 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
|
||||
443
specs/04-operations/monitoring-alerting.md
Normal file
443
specs/04-operations/monitoring-alerting.md
Normal 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
|
||||
0
specs/04-operations/monitoring.md
Normal file
0
specs/04-operations/monitoring.md
Normal file
444
specs/04-operations/security-operations.md
Normal file
444
specs/04-operations/security-operations.md
Normal 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
|
||||
353
specs/05-decisions/ADR-001-unified-workflow-engine.md
Normal file
353
specs/05-decisions/ADR-001-unified-workflow-engine.md
Normal 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/)
|
||||
432
specs/05-decisions/ADR-002-document-numbering-strategy.md
Normal file
432
specs/05-decisions/ADR-002-document-numbering-strategy.md
Normal 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)
|
||||
505
specs/05-decisions/ADR-003-file-storage-approach.md
Normal file
505
specs/05-decisions/ADR-003-file-storage-approach.md
Normal 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)
|
||||
423
specs/05-decisions/ADR-004-rbac-implementation.md
Normal file
423
specs/05-decisions/ADR-004-rbac-implementation.md
Normal 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)
|
||||
291
specs/05-decisions/ADR-005-technology-stack.md
Normal file
291
specs/05-decisions/ADR-005-technology-stack.md
Normal 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/)
|
||||
438
specs/05-decisions/ADR-006-redis-caching-strategy.md
Normal file
438
specs/05-decisions/ADR-006-redis-caching-strategy.md
Normal 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)
|
||||
352
specs/05-decisions/ADR-007-api-design-error-handling.md
Normal file
352
specs/05-decisions/ADR-007-api-design-error-handling.md
Normal 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
|
||||
388
specs/05-decisions/ADR-008-email-notification-strategy.md
Normal file
388
specs/05-decisions/ADR-008-email-notification-strategy.md
Normal 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
|
||||
383
specs/05-decisions/ADR-009-database-migration-strategy.md
Normal file
383
specs/05-decisions/ADR-009-database-migration-strategy.md
Normal 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
|
||||
464
specs/05-decisions/ADR-010-logging-monitoring-strategy.md
Normal file
464
specs/05-decisions/ADR-010-logging-monitoring-strategy.md
Normal 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
|
||||
399
specs/05-decisions/ADR-011-nextjs-app-router.md
Normal file
399
specs/05-decisions/ADR-011-nextjs-app-router.md
Normal 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
|
||||
428
specs/05-decisions/ADR-012-ui-component-library.md
Normal file
428
specs/05-decisions/ADR-012-ui-component-library.md
Normal 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
|
||||
497
specs/05-decisions/ADR-013-form-handling-validation.md
Normal file
497
specs/05-decisions/ADR-013-form-handling-validation.md
Normal 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
|
||||
400
specs/05-decisions/ADR-014-state-management.md
Normal file
400
specs/05-decisions/ADR-014-state-management.md
Normal 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
|
||||
457
specs/05-decisions/ADR-015-deployment-infrastructure.md
Normal file
457
specs/05-decisions/ADR-015-deployment-infrastructure.md
Normal 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
|
||||
451
specs/05-decisions/ADR-016-security-authentication.md
Normal file
451
specs/05-decisions/ADR-016-security-authentication.md
Normal 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)
|
||||
356
specs/05-decisions/README.md
Normal file
356
specs/05-decisions/README.md
Normal 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
623
specs/06-tasks/README.md
Normal 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
|
||||
263
specs/06-tasks/TASK-BE-001-database-migrations.md
Normal file
263
specs/06-tasks/TASK-BE-001-database-migrations.md
Normal 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
|
||||
427
specs/06-tasks/TASK-BE-002-auth-rbac.md
Normal file
427
specs/06-tasks/TASK-BE-002-auth-rbac.md
Normal 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)
|
||||
470
specs/06-tasks/TASK-BE-003-file-storage.md
Normal file
470
specs/06-tasks/TASK-BE-003-file-storage.md
Normal 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
|
||||
476
specs/06-tasks/TASK-BE-004-document-numbering.md
Normal file
476
specs/06-tasks/TASK-BE-004-document-numbering.md
Normal 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
|
||||
521
specs/06-tasks/TASK-BE-005-correspondence-module.md
Normal file
521
specs/06-tasks/TASK-BE-005-correspondence-module.md
Normal 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
|
||||
540
specs/06-tasks/TASK-BE-006-workflow-engine.md
Normal file
540
specs/06-tasks/TASK-BE-006-workflow-engine.md
Normal 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
|
||||
587
specs/06-tasks/TASK-BE-007-rfa-module.md
Normal file
587
specs/06-tasks/TASK-BE-007-rfa-module.md
Normal 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)
|
||||
584
specs/06-tasks/TASK-BE-008-drawing-module.md
Normal file
584
specs/06-tasks/TASK-BE-008-drawing-module.md
Normal 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
|
||||
578
specs/06-tasks/TASK-BE-009-circulation-transmittal.md
Normal file
578
specs/06-tasks/TASK-BE-009-circulation-transmittal.md
Normal 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
|
||||
493
specs/06-tasks/TASK-BE-010-search-elasticsearch.md
Normal file
493
specs/06-tasks/TASK-BE-010-search-elasticsearch.md
Normal 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
|
||||
524
specs/06-tasks/TASK-BE-011-notification-audit.md
Normal file
524
specs/06-tasks/TASK-BE-011-notification-audit.md
Normal 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
|
||||
641
specs/06-tasks/TASK-BE-012-master-data-management.md
Normal file
641
specs/06-tasks/TASK-BE-012-master-data-management.md
Normal 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)
|
||||
738
specs/06-tasks/TASK-BE-013-user-management.md
Normal file
738
specs/06-tasks/TASK-BE-013-user-management.md
Normal 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
|
||||
381
specs/06-tasks/TASK-FE-001-frontend-setup.md
Normal file
381
specs/06-tasks/TASK-FE-001-frontend-setup.md
Normal 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
|
||||
438
specs/06-tasks/TASK-FE-002-auth-ui.md
Normal file
438
specs/06-tasks/TASK-FE-002-auth-ui.md
Normal 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
|
||||
346
specs/06-tasks/TASK-FE-003-layout-navigation.md
Normal file
346
specs/06-tasks/TASK-FE-003-layout-navigation.md
Normal 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
|
||||
406
specs/06-tasks/TASK-FE-004-correspondence-ui.md
Normal file
406
specs/06-tasks/TASK-FE-004-correspondence-ui.md
Normal 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
|
||||
454
specs/06-tasks/TASK-FE-005-common-components.md
Normal file
454
specs/06-tasks/TASK-FE-005-common-components.md
Normal 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
|
||||
408
specs/06-tasks/TASK-FE-006-rfa-ui.md
Normal file
408
specs/06-tasks/TASK-FE-006-rfa-ui.md
Normal 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
|
||||
388
specs/06-tasks/TASK-FE-007-drawing-ui.md
Normal file
388
specs/06-tasks/TASK-FE-007-drawing-ui.md
Normal 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
|
||||
382
specs/06-tasks/TASK-FE-008-search-ui.md
Normal file
382
specs/06-tasks/TASK-FE-008-search-ui.md
Normal 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
|
||||
344
specs/06-tasks/TASK-FE-009-dashboard-notifications.md
Normal file
344
specs/06-tasks/TASK-FE-009-dashboard-notifications.md
Normal 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
|
||||
680
specs/06-tasks/TASK-FE-010-admin-panel.md
Normal file
680
specs/06-tasks/TASK-FE-010-admin-panel.md
Normal 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
|
||||
505
specs/06-tasks/TASK-FE-011-workflow-config-ui.md
Normal file
505
specs/06-tasks/TASK-FE-011-workflow-config-ui.md
Normal 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
|
||||
517
specs/06-tasks/TASK-FE-012-numbering-config-ui.md
Normal file
517
specs/06-tasks/TASK-FE-012-numbering-config-ui.md
Normal 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
|
||||
Reference in New Issue
Block a user