260223:1415 20260223 nextJS & nestJS Best pratices
All checks were successful
Build and Deploy / deploy (push) Successful in 4m44s
All checks were successful
Build and Deploy / deploy (push) Successful in 4m44s
This commit is contained in:
353
specs/06-Decision-Records/ADR-001-unified-workflow-engine.md
Normal file
353
specs/06-Decision-Records/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/02-01-system-architecture.md)
|
||||
- [Unified Workflow Requirements](../01-requirements/01-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/01-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/)
|
||||
977
specs/06-Decision-Records/ADR-002-document-numbering-strategy.md
Normal file
977
specs/06-Decision-Records/ADR-002-document-numbering-strategy.md
Normal file
@@ -0,0 +1,977 @@
|
||||
# ADR-002: Document Numbering Strategy
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-12-18
|
||||
**Decision Makers:** Development Team, System Architect
|
||||
**Related Documents:**
|
||||
|
||||
- [System Architecture](../02-architecture/02-01-system-architecture.md)
|
||||
- [Document Numbering Requirements](../01-requirements/01-03.11-document-numbering.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องสร้างเลขที่เอกสารอัตโนมัติสำหรับ Correspondence, RFA, Transmittal และ Drawing โดยเลขที่เอกสารต้อง:
|
||||
|
||||
1. **Unique:** ไม่ซ้ำกันในระบบ
|
||||
2. **Sequential:** เรียงตามลำดับเวลา
|
||||
3. **Meaningful:** มีโครงสร้างที่อ่านเข้าใจได้ (เช่น `LCBP3-C2-RFI-ROW-0029-A`)
|
||||
4. **Configurable:** สามารถปรับรูปแบบได้ตาม Project/Organization/Document Type
|
||||
5. **Concurrent-safe:** ป้องกัน Race Condition เมื่อมีหลาย Request พร้อมกัน
|
||||
|
||||
### Key Challenges
|
||||
|
||||
1. **Race Condition:** เมื่อมี 2+ requests พร้อมกัน อาจได้เลขเดียวกัน
|
||||
2. **Performance:** ต้องรวดเร็วแม้มี concurrent requests (50-100 req/sec)
|
||||
3. **Flexibility:** รองรับรูปแบบเลขที่หลากหลายตามชนิดเอกสาร
|
||||
4. **Discipline Support:** เลขที่ต้องรวม Discipline Code (GEN, STR, ARC, etc.)
|
||||
5. **Transmittal Logic:** เลขที่ Transmittal เปลี่ยนตามผู้รับ (To Owner vs To Contractor)
|
||||
6. **Year Reset:** Counter ต้อง reset ตาม ปี พ.ศ. หรือ ค.ศ.
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Data Integrity:** เลขที่ต้องไม่ซ้ำกันเด็ดขาด (Mission-Critical)
|
||||
- **Performance:** Generate เลขที่ได้เร็ว (<500ms normal, <2s p95, <5s p99)
|
||||
- **Scalability:** รองรับ 50-100 concurrent requests/second
|
||||
- **Maintainability:** ง่ายต่อการ Config และ Debug
|
||||
- **Flexibility:** รองรับ Template-based format สำหรับแต่ละ document type
|
||||
- **Auditability:** บันทึก history ของทุก generated number
|
||||
- **Security:** ป้องกัน abuse ด้วย rate limiting
|
||||
|
||||
---
|
||||
|
||||
## 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/Discipline/Year
|
||||
- ❌ ไม่รองรับ Custom format (เช่น `LCBP3-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 (<500ms)
|
||||
- ✅ **Audit Trail:** Counter history + audit log in database
|
||||
- ✅ **Configurable Format:** Template-based generation
|
||||
- ✅ **Resilient:** Fallback to DB pessimistic lock if Redis unavailable
|
||||
- ✅ **Partition Support:** Different counters per Project/Type/SubType/Discipline/Year
|
||||
- ✅ **Transmittal Logic:** Support recipient-based counting
|
||||
|
||||
**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 + Audit log ใน Database
|
||||
4. **Flexibility:** รองรับ Template-based format สำหรับทุก document type
|
||||
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 NULL COMMENT 'Specific Type ID, or NULL for Project Default', -- CHANGED: Allow NULL
|
||||
format_template VARCHAR(100) NOT NULL COMMENT 'e.g. {PROJECT}-{TYPE}-{YEAR}-{SEQ:4}',
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE,
|
||||
-- Note: Application logic must enforce single default format per project
|
||||
UNIQUE KEY unique_format (project_id, correspondence_type_id)
|
||||
) ENGINE=InnoDB COMMENT='Template configurations for document numbering';
|
||||
|
||||
-- Counter Table with Optimistic Locking
|
||||
CREATE TABLE document_number_counters (
|
||||
project_id INT NOT NULL,
|
||||
correspondence_type_id INT NULL,
|
||||
originator_organization_id INT NOT NULL,
|
||||
recipient_organization_id INT NOT NULL DEFAULT 0, -- 0 = no recipient (RFA)
|
||||
sub_type_id INT DEFAULT 0,
|
||||
rfa_type_id INT DEFAULT 0,
|
||||
discipline_id INT DEFAULT 0,
|
||||
reset_scope VARCHAR(20) NOT NULL,
|
||||
last_number INT DEFAULT 0 NOT NULL,
|
||||
version INT DEFAULT 0 NOT NULL,
|
||||
created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (
|
||||
project_id,
|
||||
originator_organization_id,
|
||||
recipient_organization_id,
|
||||
correspondence_type_id,
|
||||
sub_type_id,
|
||||
rfa_type_id,
|
||||
discipline_id,
|
||||
reset_scope
|
||||
)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Running Number Counters';
|
||||
|
||||
-- Audit Trail
|
||||
CREATE TABLE document_number_audit (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
document_id INT DEFAULT NULL COMMENT 'FK to documents (set after doc creation)',
|
||||
generated_number VARCHAR(255) NOT NULL,
|
||||
counter_key VARCHAR(500) NOT NULL COMMENT 'Redis lock key used',
|
||||
template_used VARCHAR(255) NOT NULL,
|
||||
sequence_number INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
ip_address VARCHAR(45),
|
||||
retry_count INT DEFAULT 0,
|
||||
lock_wait_ms INT DEFAULT 0 COMMENT 'Time spent waiting for lock',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_audit_number (generated_number),
|
||||
INDEX idx_audit_user (user_id, created_at),
|
||||
INDEX idx_audit_created (created_at)
|
||||
) ENGINE=InnoDB COMMENT='Audit trail for all generated document numbers';
|
||||
```
|
||||
|
||||
### Token Types Reference
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Updated to align with Requirements Specification**
|
||||
>
|
||||
> This ADR now uses token names from [03.11-document-numbering.md](../01-requirements/01-03.11-document-numbering.md) for consistency.
|
||||
|
||||
รองรับ Token ทั้งหมด:
|
||||
|
||||
| Token | Description | Example Value | Database Source |
|
||||
| -------------- | ------------------------- | ------------------------------ | --------------------------------------------------------------------- |
|
||||
| `{PROJECT}` | รหัสโครงการ | `LCBP3`, `LCBP3-C2` | `projects.project_code` |
|
||||
| `{ORIGINATOR}` | รหัสองค์กรผู้ส่ง | `คคง.`, `ผรม.1` | `organizations.organization_code` via `correspondences.originator_id` |
|
||||
| `{RECIPIENT}` | รหัสองค์กรผู้รับหลัก (TO) | `สคฉ.3`, `กทท.` | `organizations.organization_code` via `correspondence_recipients` |
|
||||
| `{CORR_TYPE}` | รหัสประเภทเอกสาร | `RFA`, `TRANSMITTAL`, `LETTER` | `correspondence_types.type_code` |
|
||||
| `{SUB_TYPE}` | หมายเลขประเภทย่อย | `11`, `12`, `21` | `correspondence_sub_types.sub_type_number` |
|
||||
| `{RFA_TYPE}` | รหัสประเภท RFA | `SDW`, `RPT`, `MAT` | `rfa_types.type_code` |
|
||||
| `{DISCIPLINE}` | รหัสสาขาวิชา | `STR`, `TER`, `GEO` | `disciplines.discipline_code` |
|
||||
| `{SEQ:n}` | Running number (n digits) | `0001`, `0029`, `0985` | `document_number_counters.last_number + 1` |
|
||||
| `{YEAR:B.E.}` | ปี พ.ศ. | `2568` | `current_year + 543` |
|
||||
| `{YEAR:A.D.}` | ปี ค.ศ. | `2025` | `current_year` |
|
||||
| `{REV}` | Revision Code | `A`, `B`, `AA` | `correspondence_revisions.revision_label` |
|
||||
|
||||
> [!WARNING]
|
||||
> **Deprecated Token Names (DO NOT USE)**
|
||||
>
|
||||
> The following tokens were used in earlier drafts but are now **deprecated**:
|
||||
> - ~~`{ORG}`~~ → Use `{ORIGINATOR}` or `{RECIPIENT}` (explicit roles)
|
||||
> - ~~`{TYPE}`~~ → Use `{CORR_TYPE}`, `{SUB_TYPE}`, or `{RFA_TYPE}` (context-specific)
|
||||
> - ~~`{CATEGORY}`~~ → Not used in current system
|
||||
>
|
||||
> **Always refer to**: [03.11-document-numbering.md](../01-requirements/01-03.11-document-numbering.md) as source of truth
|
||||
|
||||
### Format Resolution Strategy (Fallback Logic)
|
||||
|
||||
The system resolves the numbering format using the following priority:
|
||||
1. **Specific Format:** Search for a record matching both `project_id` and `correspondence_type_id`.
|
||||
2. **Default Format:** If not found, search for a record with matching `project_id` where `correspondence_type_id` is `NULL`.
|
||||
3. **System Fallback:** If neither exists, use the hardcoded system default: `{ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE}`.
|
||||
|
||||
| Priority | Scenario | Template Source | Counter Scope (Key) | Reset Behavior |
|
||||
| -------- | --------------------- | --------------------------------------------------- | ----------------------------- | ----------------------------------- |
|
||||
| 1 | Specific Format Found | Database (project_id, type_id) | Specific Type (type_id) | Based on reset_sequence_yearly flag |
|
||||
| 2 | Default Format Found | Database (project_id, type_id=NULL) | Shared Counter (type_id=NULL) | Based on reset_sequence_yearly flag |
|
||||
| 3 | Fallback (No Config) | System Default: {ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE} | Shared Counter (type_id=NULL) | Reset Yearly (Default: True) |
|
||||
|
||||
### Format Examples by Document Type
|
||||
|
||||
#### 1. Correspondence (หนังสือราชการ)
|
||||
|
||||
**Letter:**
|
||||
|
||||
```
|
||||
Template: {ORIGINATOR}-{RECIPIENT}-{SEQ:4}-{YEAR:B.E.}
|
||||
Example: คคง.-สคฉ.3-0001-2568
|
||||
Counter Key: (project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)
|
||||
```
|
||||
|
||||
> **Note**: `{CORR_TYPE}` ไม่แสดงใน template เพื่อความกระชับ แต่ยังใช้ `correspondence_type_id` ใน Counter Key เพื่อแยก counter
|
||||
|
||||
**Other Types (RFI, MEMO, etc.):**
|
||||
|
||||
```
|
||||
Template: {ORIGINATOR}-{RECIPIENT}-{SEQ:4}-{YEAR:B.E.}
|
||||
Example (RFI): คคง.-สคฉ.3-0042-2568
|
||||
Example (MEMO): คคง.-ผรม.1-0001-2568
|
||||
Counter Key: (project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)
|
||||
```
|
||||
|
||||
> **Note**: แต่ละ type มี counter แยกกันผ่าน `correspondence_type_id`
|
||||
|
||||
#### 2. Transmittal
|
||||
|
||||
**Standard Format:**
|
||||
|
||||
```
|
||||
Template: {ORIGINATOR}-{RECIPIENT}-{SUB_TYPE}-{SEQ:4}-{YEAR:B.E.}
|
||||
Example: คคง.-สคฉ.3-21-0117-2568
|
||||
Counter Key: (project_id, originator_org_id, recipient_org_id, corr_type_id, sub_type_id, 0, 0, year)
|
||||
```
|
||||
|
||||
**Token Breakdown:**
|
||||
- `คคง.` = `{ORIGINATOR}` - ผู้ส่ง
|
||||
- `สคฉ.3` = `{RECIPIENT}` - ผู้รับหลัก (TO)
|
||||
- `21` = `{SUB_TYPE}` - หมายเลขประเภทย่อย (11=MAT, 12=SHP, 13=DWG, 21=...)
|
||||
- `0117` = `{SEQ:4}` - Running number
|
||||
- `2568` = `{YEAR:B.E.}` - ปี พ.ศ.
|
||||
|
||||
> **Note**: `{CORR_TYPE}` ไม่แสดงใน template (เหมือน LETTER) เพื่อความกระชับ
|
||||
|
||||
#### 3. RFA (Request for Approval)
|
||||
|
||||
```
|
||||
Template: {PROJECT}-{CORR_TYPE}-{DISCIPLINE}-{RFA_TYPE}-{SEQ:4}-{REV}
|
||||
Example: LCBP3-C2-RFA-TER-RPT-0001-A
|
||||
Counter Key: (project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, discipline_id, year)
|
||||
```
|
||||
|
||||
**Token Breakdown:**
|
||||
- `LCBP3-C2` = `{PROJECT}` - รหัสโครงการ
|
||||
- `RFA` = `{CORR_TYPE}` - ประเภทเอกสาร (**แสดง**ในtemplate สำหรับ RFA เท่านั้น)
|
||||
- `TER` = `{DISCIPLINE}` - รหัสสาขา (TER=Terminal, STR=Structure, GEO=Geotechnical)
|
||||
- `RPT` = `{RFA_TYPE}` - ประเภท RFA (RPT=Report, SDW=Shop Drawing, MAT=Material)
|
||||
- `0001` = `{SEQ:4}` - Running number
|
||||
- `A` = `{REV}` - Revision code
|
||||
|
||||
> **RFA Workflow**: เป็น Project-level document (ไม่ระบุ `recipient_organization_id` ใน counter key → NULL)
|
||||
> **Workflow Path**: CONTRACTOR → CONSULTANT → OWNER
|
||||
|
||||
#### 4. Drawing
|
||||
|
||||
```
|
||||
Template: {PROJECT}-{DISCIPLINE}-{CATEGORY}-{SEQ:4}-{REV}
|
||||
Example: LCBP3-STR-DRW-0001-A
|
||||
Counter Key: project_id + doc_type_id + discipline_id + category + year
|
||||
```
|
||||
|
||||
### NestJS Service Implementation (Simplified)
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/document-numbering/document-numbering.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import Redlock from 'redlock';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
interface NumberingContext {
|
||||
projectId: number;
|
||||
docTypeId: number;
|
||||
subTypeId?: number;
|
||||
disciplineId?: number;
|
||||
recipientType?: 'OWNER' | 'CONTRACTOR' | 'CONSULTANT' | 'OTHER';
|
||||
year?: number;
|
||||
userId: number;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentNumberingService {
|
||||
private readonly logger = new Logger(DocumentNumberingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DocumentNumberCounter)
|
||||
private counterRepo: Repository<DocumentNumberCounter>,
|
||||
@InjectRepository(DocumentNumberConfig)
|
||||
private configRepo: Repository<DocumentNumberConfig>,
|
||||
@InjectRepository(DocumentNumberAudit)
|
||||
private auditRepo: Repository<DocumentNumberAudit>,
|
||||
private redis: Redis,
|
||||
private redlock: Redlock
|
||||
) {}
|
||||
|
||||
async generateNextNumber(context: NumberingContext): Promise<string> {
|
||||
const year = context.year || new Date().getFullYear() + 543; // พ.ศ.
|
||||
const subTypeId = context.subTypeId || 0; // Fallback for NULL
|
||||
const disciplineId = context.disciplineId || 0; // Fallback for NULL
|
||||
|
||||
// Build Redis lock key
|
||||
const lockKey = this.buildLockKey(
|
||||
context.projectId,
|
||||
context.docTypeId,
|
||||
subTypeId,
|
||||
disciplineId,
|
||||
context.recipientType,
|
||||
year
|
||||
);
|
||||
|
||||
// Retry with exponential backoff (Scenario 2 & 3)
|
||||
return this.retryWithBackoff(
|
||||
async () => await this.generateNumberWithLock(
|
||||
lockKey,
|
||||
context,
|
||||
year,
|
||||
subTypeId,
|
||||
disciplineId
|
||||
),
|
||||
5, // Max 5 retries
|
||||
1000 // Initial delay 1s
|
||||
);
|
||||
}
|
||||
|
||||
private async generateNumberWithLock(
|
||||
lockKey: string,
|
||||
context: NumberingContext,
|
||||
year: number,
|
||||
subTypeId: number,
|
||||
disciplineId: number
|
||||
): Promise<string> {
|
||||
let lock: any;
|
||||
const lockStartTime = Date.now();
|
||||
|
||||
try {
|
||||
// Scenario 1: Redis Unavailable - Fallback to DB lock
|
||||
try {
|
||||
// Step 1: Acquire Redis Distributed Lock (TTL: 5 seconds)
|
||||
lock = await this.redlock.acquire([lockKey], 5000);
|
||||
} catch (redisError) {
|
||||
this.logger.warn(`Redis lock failed, falling back to DB lock: ${redisError.message}`);
|
||||
// Fallback: Use SELECT ... FOR UPDATE (Pessimistic Lock)
|
||||
return await this.generateWithDatabaseLock(context, year, subTypeId, disciplineId);
|
||||
}
|
||||
|
||||
const lockWaitMs = Date.now() - lockStartTime;
|
||||
|
||||
// Step 2: Query current counter with version
|
||||
let counter = await this.counterRepo.findOne({
|
||||
where: {
|
||||
project_id: context.projectId,
|
||||
doc_type_id: context.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: context.recipientType || null,
|
||||
year: year,
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize counter if not exists
|
||||
if (!counter) {
|
||||
counter = this.counterRepo.create({
|
||||
project_id: context.projectId,
|
||||
doc_type_id: context.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: context.recipientType || null,
|
||||
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 check (Scenario 3)
|
||||
const result = await this.counterRepo
|
||||
.createQueryBuilder()
|
||||
.update(DocumentNumberCounter)
|
||||
.set({
|
||||
last_number: nextNumber,
|
||||
version: () => 'version + 1',
|
||||
})
|
||||
.where({
|
||||
project_id: context.projectId,
|
||||
doc_type_id: context.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: context.recipientType || null,
|
||||
year: year,
|
||||
version: currentVersion, // Optimistic lock check
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (result.affected === 0) {
|
||||
throw new ConflictException('Counter version conflict - retrying...');
|
||||
}
|
||||
|
||||
// Step 4: Generate formatted number
|
||||
const config = await this.getConfig(
|
||||
context.projectId,
|
||||
context.docTypeId,
|
||||
subTypeId,
|
||||
disciplineId
|
||||
);
|
||||
|
||||
const formattedNumber = await this.formatNumber(config.template, {
|
||||
...context,
|
||||
year,
|
||||
sequenceNumber: nextNumber,
|
||||
});
|
||||
|
||||
// Step 5: Audit logging
|
||||
await this.auditRepo.save({
|
||||
generated_number: formattedNumber,
|
||||
counter_key: lockKey,
|
||||
template_used: config.template,
|
||||
sequence_number: nextNumber,
|
||||
user_id: context.userId,
|
||||
ip_address: context.ipAddress,
|
||||
retry_count: 0,
|
||||
lock_wait_ms: lockWaitMs,
|
||||
});
|
||||
|
||||
this.logger.log(`Generated: ${formattedNumber} (wait: ${lockWaitMs}ms)`);
|
||||
return formattedNumber;
|
||||
|
||||
} finally {
|
||||
// Step 6: Release Redis lock
|
||||
if (lock) {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async formatNumber(template: string, data: any): Promise<string> {
|
||||
// Token replacement logic
|
||||
const tokens = {
|
||||
'{PROJECT}': await this.getProjectCode(data.projectId),
|
||||
'{ORIGINATOR}': await this.getOriginatorOrgCode(data.originatorOrgId),
|
||||
'{RECIPIENT}': await this.getRecipientOrgCode(data.recipientOrgId),
|
||||
'{CORR_TYPE}': await this.getCorrespondenceTypeCode(data.corrTypeId),
|
||||
'{SUB_TYPE}': await this.getSubTypeCode(data.subTypeId),
|
||||
'{RFA_TYPE}': await this.getRfaTypeCode(data.rfaTypeId),
|
||||
'{DISCIPLINE}': await this.getDisciplineCode(data.disciplineId),
|
||||
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
|
||||
'{SEQ:5}': data.sequenceNumber.toString().padStart(5, '0'),
|
||||
'{YEAR:B.E.}': data.year.toString(),
|
||||
'{YEAR:A.D.}': (data.year - 543).toString(),
|
||||
'{REV}': data.revisionCode || 'A',
|
||||
};
|
||||
|
||||
let result = template;
|
||||
for (const [token, value] of Object.entries(tokens)) {
|
||||
result = result.replace(new RegExp(token, 'g'), value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private buildLockKey(...parts: Array<number | string | null | undefined>): string {
|
||||
return `doc_num:${parts.filter(p => p !== null && p !== undefined).join(':')}`;
|
||||
}
|
||||
|
||||
// Scenario 2: Lock Acquisition Timeout - Exponential Backoff
|
||||
private async retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
initialDelay: number
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const isRetryable =
|
||||
error instanceof ConflictException ||
|
||||
error.code === 'ECONNREFUSED' || // Scenario 4
|
||||
error.code === 'ETIMEDOUT'; // Scenario 4
|
||||
|
||||
if (!isRetryable || attempt === maxRetries) {
|
||||
if (attempt === maxRetries) {
|
||||
throw new ServiceUnavailableException(
|
||||
'ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง'
|
||||
);
|
||||
}
|
||||
throw 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 1: Fallback to Database Lock
|
||||
private async generateWithDatabaseLock(
|
||||
context: NumberingContext,
|
||||
year: number,
|
||||
subTypeId: number,
|
||||
disciplineId: number
|
||||
): Promise<string> {
|
||||
return await this.counterRepo.manager.transaction(async (manager) => {
|
||||
// Pessimistic lock: SELECT ... FOR UPDATE
|
||||
const counter = await manager
|
||||
.createQueryBuilder(DocumentNumberCounter, 'counter')
|
||||
.setLock('pessimistic_write')
|
||||
.where({
|
||||
project_id: context.projectId,
|
||||
doc_type_id: context.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: context.recipientType || null,
|
||||
year: year,
|
||||
})
|
||||
.getOne();
|
||||
|
||||
const nextNumber = (counter?.last_number || 0) + 1;
|
||||
|
||||
// Update counter
|
||||
await manager.save(DocumentNumberCounter, {
|
||||
...counter,
|
||||
last_number: nextNumber,
|
||||
});
|
||||
|
||||
// Format and return
|
||||
const config = await this.getConfig(context.projectId, context.docTypeId, subTypeId, disciplineId);
|
||||
return await this.formatNumber(config.template, {
|
||||
...context,
|
||||
year,
|
||||
sequenceNumber: nextNumber,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Algorithm Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Service as Numbering Service
|
||||
participant Redis
|
||||
participant DB as MariaDB
|
||||
participant Audit
|
||||
|
||||
Client->>Service: generateNextNumber(context)
|
||||
Service->>Redis: ACQUIRE Lock (key, TTL=5s)
|
||||
|
||||
alt Redis Available
|
||||
Redis-->>Service: Lock Success
|
||||
Service->>DB: SELECT counter (with version)
|
||||
DB-->>Service: current_number, version
|
||||
Service->>DB: UPDATE counter SET last_number=X, version=version+1<br/>WHERE version=old_version
|
||||
|
||||
alt Update Success (No Conflict)
|
||||
DB-->>Service: Success (1 row affected)
|
||||
Service->>Service: Format Number with Template
|
||||
Service->>Audit: Log generated number + metadata
|
||||
Service->>Redis: RELEASE Lock
|
||||
Service-->>Client: Document Number
|
||||
else Version Conflict (Scenario 3)
|
||||
DB-->>Service: Failed (0 rows affected)
|
||||
Service->>Redis: RELEASE Lock
|
||||
Service->>Service: Retry with Exponential Backoff (2x)
|
||||
Note over Service: If still fail after 2 retries:<br/>Return 409 Conflict
|
||||
end
|
||||
else Redis Unavailable (Scenario 1)
|
||||
Redis-->>Service: Connection Error
|
||||
Service->>DB: BEGIN TRANSACTION
|
||||
Service->>DB: SELECT ... FOR UPDATE (Pessimistic Lock)
|
||||
DB-->>Service: Counter (locked)
|
||||
Service->>DB: UPDATE counter
|
||||
Service->>DB: COMMIT
|
||||
Service-->>Client: Document Number (slower but works)
|
||||
end
|
||||
|
||||
alt Lock Timeout (Scenario 2)
|
||||
Redis-->>Service: Lock Acquisition Timeout
|
||||
Service->>Service: Retry 5 times with backoff<br/>(1s, 2s, 4s, 8s, 16s)
|
||||
Note over Service: If all retries fail:<br/>Return 503 Service Unavailable
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Scenarios
|
||||
|
||||
### Scenario 1: Redis Unavailable
|
||||
|
||||
**Trigger:** Redis connection error, Redis down
|
||||
|
||||
**Fallback:**
|
||||
|
||||
- ใช้ Database-only locking (`SELECT ... FOR UPDATE`)
|
||||
- Log warning และแจ้ง ops team
|
||||
- ระบบยังใช้งานได้แต่ performance ลดลง (slower)
|
||||
|
||||
### Scenario 2: Lock Acquisition Timeout
|
||||
|
||||
**Trigger:** หลาย requests แย่งชิง lock พร้อมกัน
|
||||
|
||||
**Retry Logic:**
|
||||
|
||||
- Retry 5 ครั้งด้วย exponential backoff: 1s, 2s, 4s, 8s, 16s (รวม ~31 วินาที)
|
||||
- หลัง 5 ครั้ง: Return HTTP 503 "Service Temporarily Unavailable"
|
||||
- Frontend: แสดง "ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง"
|
||||
|
||||
### Scenario 3: Version Conflict After Lock
|
||||
|
||||
**Trigger:** Optimistic lock version mismatch
|
||||
|
||||
**Retry Logic:**
|
||||
|
||||
- Retry 2 ครั้ง (reload counter + retry transaction)
|
||||
- หลัง 2 ครั้ง: Return HTTP 409 Conflict
|
||||
- Frontend: แสดง "เลขที่เอกสารถูกเปลี่ยน กรุณาลองใหม่"
|
||||
|
||||
### Scenario 4: Database Connection Error
|
||||
|
||||
**Trigger:** Database connection timeout, connection pool exhausted
|
||||
|
||||
**Retry Logic:**
|
||||
|
||||
- Retry 3 ครั้งด้วย exponential backoff: 1s, 2s, 4s
|
||||
- หลัง 3 ครั้ง: Return HTTP 500 "Internal Server Error"
|
||||
- Frontend: แสดง "เกิดข้อผิดพลาดในระบบ กรุณาติดต่อผู้ดูแลระบบ"
|
||||
|
||||
---
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
### Response Time Targets
|
||||
|
||||
| Metric | Target | Description |
|
||||
| ---------------- | ---------- | ----------------------------------- |
|
||||
| Normal Operation | <500ms | Under normal load, no conflicts |
|
||||
| 95th Percentile | <2 seconds | Including retry scenarios |
|
||||
| 99th Percentile | <5 seconds | Extreme cases with multiple retries |
|
||||
|
||||
### Throughput Targets
|
||||
|
||||
| Load Level | Target | Notes |
|
||||
| ----------- | ----------- | ----------------------------- |
|
||||
| Normal Load | 50 req/sec | Typical office hours |
|
||||
| Peak Load | 100 req/sec | Construction deadline periods |
|
||||
|
||||
### Availability
|
||||
|
||||
- **Uptime:** ≥99.5% (exclude planned maintenance)
|
||||
- **Maximum Downtime:** ≤3.6 hours/month
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Alerting
|
||||
|
||||
### Metrics to Track
|
||||
|
||||
1. **Lock Acquisition Metrics:**
|
||||
- Lock wait time (p50, p95, p99)
|
||||
- Lock acquisition success rate
|
||||
- Lock timeout count
|
||||
|
||||
2. **Counter Generation:**
|
||||
- Generation latency (p50, p95, p99)
|
||||
- Generation success rate
|
||||
- Retry count distribution
|
||||
|
||||
3. **System Health:**
|
||||
- Redis connection status
|
||||
- Database connection pool usage
|
||||
- Error rate by scenario (1-4)
|
||||
|
||||
### Alert Conditions
|
||||
|
||||
| Severity | Condition | Action |
|
||||
| ---------- | ---------------------------- | ------------------ |
|
||||
| 🔴 Critical | Redis unavailable >1 minute | Page ops team |
|
||||
| 🔴 Critical | Lock failures >10% in 5 min | Page ops team |
|
||||
| 🟡 Warning | Lock failures >5% in 5 min | Alert ops team |
|
||||
| 🟡 Warning | Avg lock wait time >1 second | Alert ops team |
|
||||
| 🟡 Warning | Retry count >100/hour | Review system load |
|
||||
|
||||
### Dashboard Panels
|
||||
|
||||
- Real-time lock acquisition success rate (%)
|
||||
- Lock wait time percentiles chart
|
||||
- Counter generation rate (per minute)
|
||||
- Error rate breakdown by type
|
||||
- Redis/Database health indicators
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authorization
|
||||
|
||||
- เฉพาะ **authenticated users** สามารถ request document number
|
||||
- เฉพาะ **Project Admin** สามารถแก้ไข template
|
||||
- เฉพาะ **Super Admin** สามารถ reset counter
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Prevent abuse และ resource exhaustion:
|
||||
|
||||
| Scope | Limit | Window |
|
||||
| -------------- | ------------- | -------- |
|
||||
| Per User | 10 requests | 1 minute |
|
||||
| Per IP Address | 50 requests | 1 minute |
|
||||
| Global | 5000 requests | 1 minute |
|
||||
|
||||
**Implementation:** ใช้ Redis-based rate limiter middleware
|
||||
|
||||
### Audit & Compliance
|
||||
|
||||
- บันทึกทุก API call ที่เกี่ยวข้องกับ document numbering
|
||||
- เก็บ audit log อย่างน้อย **7 ปี** (ตาม พ.ร.บ. ข้อมูลอิเล็กทรอนิกส์)
|
||||
- บันทึก: user, IP, timestamp, generated number, retry count
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Zero Duplicate Risk:** Double-lock + DB constraint guarantees uniqueness
|
||||
2. ✅ **High Performance:** Redis lock + optimistic locking (<500ms normal)
|
||||
3. ✅ **Complete Audit Trail:** All counters + generated numbers in database
|
||||
4. ✅ **Highly Configurable:** Template-based for all document types
|
||||
5. ✅ **Partition Support:** Separate counters per Project/Type/SubType/Discipline/Recipient/Year
|
||||
6. ✅ **Resilient:** Multiple fallback strategies for all failure scenarios
|
||||
7. ✅ **Transmittal Logic:** Supports recipient-based numbering
|
||||
8. ✅ **Security:** Rate limiting + authorization + audit logging
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Complexity:** Requires coordination between Redis and Database
|
||||
2. ❌ **Dependencies:** Requires both Redis and DB healthy for optimal performance
|
||||
3. ❌ **Retry Logic:** May retry causing delays under high contention
|
||||
4. ❌ **Monitoring Overhead:** Need comprehensive monitoring for all scenarios
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Redis Dependency:** Use Redis Persistence (AOF) + Replication + Fallback to DB
|
||||
- **Complexity:** Encapsulate all logic in `DocumentNumberingService`
|
||||
- **Retry Delays:** Exponential backoff limits max delay time
|
||||
- **Monitoring:** Automated dashboards + alerting for all critical metrics
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('DocumentNumberingService - Concurrent Generation', () => {
|
||||
it('should generate unique numbers for 100 concurrent requests', async () => {
|
||||
const context: NumberingContext = {
|
||||
projectId: 1,
|
||||
docTypeId: 2, // RFA
|
||||
disciplineId: 3, // STR
|
||||
year: 2568,
|
||||
userId: 1,
|
||||
ipAddress: '192.168.1.1',
|
||||
};
|
||||
|
||||
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(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use correct format for Transmittal To Owner', async () => {
|
||||
const number = await service.generateNextNumber({
|
||||
projectId: 1,
|
||||
docTypeId: 3, // Transmittal
|
||||
recipientType: 'OWNER',
|
||||
year: 2568,
|
||||
userId: 1,
|
||||
ipAddress: '192.168.1.1',
|
||||
});
|
||||
|
||||
expect(number).toMatch(/^คคง\.-สคฉ\.3-03-21-\d{4}-2568$/);
|
||||
});
|
||||
|
||||
it('should fallback to DB lock when Redis unavailable', async () => {
|
||||
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Redis down'));
|
||||
|
||||
const number = await service.generateNextNumber(context);
|
||||
expect(number).toBeDefined();
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('falling back to DB lock'));
|
||||
});
|
||||
|
||||
it('should retry on version conflict and succeed', async () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('should throw 503 after max lock acquisition retries', async () => {
|
||||
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
|
||||
|
||||
await expect(service.generateNextNumber(context))
|
||||
.rejects
|
||||
.toThrow(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
|
||||
```yaml
|
||||
# artillery.yml
|
||||
config:
|
||||
target: 'http://localhost:3000'
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 50 # 50 requests/second
|
||||
name: 'Normal Load'
|
||||
- duration: 30
|
||||
arrivalRate: 100 # 100 requests/second
|
||||
name: 'Peak Load'
|
||||
|
||||
scenarios:
|
||||
- name: 'Generate Document Numbers - RFA'
|
||||
weight: 40
|
||||
flow:
|
||||
- post:
|
||||
url: '/api/v1/rfa'
|
||||
json:
|
||||
title: 'Load Test {{ $randomString() }}'
|
||||
project_id: 1
|
||||
doc_type_id: 2
|
||||
discipline_id: 3
|
||||
|
||||
- name: 'Generate Document Numbers - Transmittal'
|
||||
weight: 30
|
||||
flow:
|
||||
- post:
|
||||
url: '/api/v1/transmittals'
|
||||
json:
|
||||
title: 'Load Test {{ $randomString() }}'
|
||||
project_id: 1
|
||||
doc_type_id: 3
|
||||
recipient_type: 'OWNER'
|
||||
|
||||
- name: 'Generate Document Numbers - Correspondence'
|
||||
weight: 30
|
||||
flow:
|
||||
- post:
|
||||
url: '/api/v1/correspondences'
|
||||
json:
|
||||
title: 'Load Test {{ $randomString() }}'
|
||||
project_id: 1
|
||||
doc_type_id: 1
|
||||
|
||||
expect:
|
||||
- statusCode: 200
|
||||
- statusCode: 201
|
||||
- contentType: json
|
||||
|
||||
ensure:
|
||||
p95: 2000 # 95th percentile < 2 seconds
|
||||
p99: 5000 # 99th percentile < 5 seconds
|
||||
maxErrorRate: 0.1 # < 0.1% errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
เป็นไปตาม:
|
||||
|
||||
- ✅ [Requirements 3.11](../01-requirements/01-03.11-document-numbering.md) - Document Numbering Management (v1.6.2)
|
||||
- ✅ [Implementation Guide](../03-implementation/03-04-document-numbering.md) - DocumentNumberingModule (v1.6.1)
|
||||
- ✅ [Operations Guide](../04-operations/04-08-document-numbering-operations.md) - Monitoring & Troubleshooting
|
||||
- ✅ [Security Best Practices](../02-architecture/security-architecture.md) - Rate Limiting, Audit Logging
|
||||
|
||||
---
|
||||
|
||||
## 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 details
|
||||
- [ADR-006: Audit Logging Strategy](./ADR-006-audit-logging-strategy.md) - Comprehensive audit requirements
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Redlock Algorithm](https://redis.io/topics/distlock) - Distributed locking with Redis
|
||||
- [TypeORM Optimistic Locking](https://typeorm.io/entities#version-column) - Version column usage
|
||||
- [Distributed Lock Patterns](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) - Martin Kleppmann's analysis
|
||||
- [Redis Persistence](https://redis.io/topics/persistence) - AOF and RDB strategies
|
||||
- [Rate Limiting Patterns](https://redis.io/glossary/rate-limiting/) - Redis-based rate limiting
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
| ------- | ---------- | ------------------------------------------------------------------------------------------------- |
|
||||
| 1.0 | 2025-11-30 | Initial decision |
|
||||
| 2.0 | 2025-12-02 | Updated with comprehensive error scenarios, monitoring, security, and all token types |
|
||||
| 3.0 | 2025-12-17 | Aligned with Requirements v1.6.2: updated counter schema, token definitions, Number State Machine |
|
||||
291
specs/06-Decision-Records/ADR-005-technology-stack.md
Normal file
291
specs/06-Decision-Records/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/02-01-system-architecture.md)
|
||||
- [FullStack JS Guidelines](../03-implementation/03-01-fullftack-js-v1.7.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 11.8 | 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 19 | 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 11.8 | 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 11.8 (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/03-01-fullftack-js-v1.7.0.md)
|
||||
- [Backend Guidelines](../03-implementation/03-02-backend-guidelines.md)
|
||||
- [Frontend Guidelines](../03-implementation/03-03-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/06-Decision-Records/ADR-006-redis-caching-strategy.md
Normal file
438
specs/06-Decision-Records/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/02-01-system-architecture.md)
|
||||
- [Performance Requirements](../01-requirements/01-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/02-01-system-architecture.md#redis)
|
||||
- [Performance Requirements](../01-requirements/01-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)
|
||||
388
specs/06-Decision-Records/ADR-008-email-notification-strategy.md
Normal file
388
specs/06-Decision-Records/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/03-02-backend-guidelines.md), [TASK-BE-011](../06-tasks/README.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/README.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/06-Decision-Records/ADR-009-database-migration-strategy.md
Normal file
383
specs/06-Decision-Records/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-015-schema-v160-migration.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-015-schema-v160-migration.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/06-Decision-Records/ADR-010-logging-monitoring-strategy.md
Normal file
464
specs/06-Decision-Records/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/03-02-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/06-Decision-Records/ADR-011-nextjs-app-router.md
Normal file
399
specs/06-Decision-Records/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/03-03-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/06-Decision-Records/ADR-012-ui-component-library.md
Normal file
428
specs/06-Decision-Records/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/03-03-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/06-Decision-Records/ADR-013-form-handling-validation.md
Normal file
497
specs/06-Decision-Records/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/03-03-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
|
||||
404
specs/06-Decision-Records/ADR-014-state-management.md
Normal file
404
specs/06-Decision-Records/ADR-014-state-management.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# ADR-014: State Management Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Frontend Team
|
||||
**Related Documents:** [Frontend Guidelines](../03-implementation/03-03-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) + TanStack Query (Server State) + React Hook Form + Zod (Form State)**
|
||||
|
||||
### Rationale
|
||||
|
||||
**For Client State (UI state, Preferences):**
|
||||
|
||||
- Use **Zustand** - lightweight และเรียนรู้ง่าย
|
||||
|
||||
**For Server State (API data):**
|
||||
|
||||
- Use **TanStack Query** (React Query) สำหรับ data fetching, caching, synchronization
|
||||
- Server Components สำหรับ initial data loading
|
||||
|
||||
**For Form State:**
|
||||
|
||||
- Use **React Hook Form + Zod** สำหรับ type-safe form management
|
||||
|
||||
---
|
||||
|
||||
## 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 TanStack Query)
|
||||
|
||||
```bash
|
||||
npm install @tanstack/react-query
|
||||
```
|
||||
|
||||
```typescript
|
||||
// File: components/correspondences/correspondence-list.tsx
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCorrespondences } from '@/lib/api/correspondences';
|
||||
|
||||
export function CorrespondenceList() {
|
||||
const { data, error, isLoading, refetch } = useQuery({
|
||||
queryKey: ['correspondences'],
|
||||
queryFn: getCorrespondences,
|
||||
refetchInterval: 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={() => refetch()}>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 TanStack Query (Client-Side Server State)
|
||||
|
||||
✅ Use TanStack Query for:
|
||||
|
||||
- Real-time data (notifications count)
|
||||
- Polling/Auto-refresh data
|
||||
- User-specific data that changes often
|
||||
- Optimistic UI updates
|
||||
- Complex cache invalidation
|
||||
- Paginated/infinite scroll data
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- [TanStack Query Documentation](https://tanstack.com/query/latest)
|
||||
- [React Hook Form Documentation](https://react-hook-form.com/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-02-20
|
||||
**Next Review:** 2026-06-01
|
||||
457
specs/06-Decision-Records/ADR-015-deployment-infrastructure.md
Normal file
457
specs/06-Decision-Records/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:11.8
|
||||
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/06-Decision-Records/ADR-016-security-authentication.md
Normal file
451
specs/06-Decision-Records/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)
|
||||
360
specs/06-Decision-Records/README.md
Normal file
360
specs/06-Decision-Records/README.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Architecture Decision Records (ADRs)
|
||||
|
||||
**Version:** 1.7.0
|
||||
**Last Updated:** 2025-12-18
|
||||
**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 (Redis Redlock + DB Optimistic) เพื่อป้องกัน Race Condition
|
||||
- 📋 [Requirements](../01-requirements/01-03.11-document-numbering.md)
|
||||
- 📘 [Implementation Guide](../03-implementation/03-04-document-numbering.md)
|
||||
- 📗 [Operations Guide](../04-operations/04-08-document-numbering-operations.md)
|
||||
- **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/02-01-system-architecture.md) - สถาปัตยกรรมระบบโดยรวม
|
||||
- [Data Model](../02-architecture/02-03-data-model.md) - โครงสร้างฐานข้อมูล
|
||||
- [API Design](../02-architecture/02-02-api-design.md) - การออกแบบ API
|
||||
- [Backend Guidelines](../03-implementation/03-02-backend-guidelines.md) - มาตรฐานการพัฒนา Backend
|
||||
- [Frontend Guidelines](../03-implementation/03-03-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.7.0
|
||||
**Last Review:** 2025-12-18
|
||||
Reference in New Issue
Block a user