Main: revise specs to 1.5.0 (completed)
This commit is contained in:
353
specs/05-decisions/ADR-001-unified-workflow-engine.md
Normal file
353
specs/05-decisions/ADR-001-unified-workflow-engine.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# ADR-001: Unified Workflow Engine
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-11-30
|
||||
**Decision Makers:** Development Team, System Architect
|
||||
**Related Documents:**
|
||||
|
||||
- [System Architecture](../02-architecture/system-architecture.md)
|
||||
- [Unified Workflow Requirements](../01-requirements/03.6-unified-workflow.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องจัดการเอกสารหลายประเภท (Correspondences, RFAs, Circulations) โดยแต่ละประเภทมี Workflow การเดินเอกสารที่แตกต่างกัน:
|
||||
|
||||
- **Correspondence Routing:** ส่งเอกสารระหว่างองค์กร มีการ Forward, Reply
|
||||
- **RFA Approval Workflow:** ส่งขออนุมัติ มีขั้นตอน Review → Approve → Respond
|
||||
- **Circulation Workflow:** เวียนเอกสารภายในองค์กร มีการ Assign ผู้รับเพื่อพิจารณา
|
||||
|
||||
### Key Problems
|
||||
|
||||
1. **Code Duplication:** หากสร้างตาราง Routing แยกกันสำหรับแต่ละประเภทเอกสาร จะมี Logic ซ้ำซ้อน
|
||||
2. **Complexity:** การ Maintain หลาย Workflow Systems ทำให้ซับซ้อน
|
||||
3. **Inconsistency:** State Management และ History Tracking อาจไม่สอดคล้องกัน
|
||||
4. **Scalability:** เมื่อเพิ่มประเภทเอกสารใหม่ ต้องสร้าง Workflow System ใหม่
|
||||
5. **Versioning:** การแก้ไข Workflow กระทบเอกสารที่กำลังดำเนินการอยู่
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **DRY Principle:** Don't Repeat Yourself - ลดการเขียน Code ซ้ำ
|
||||
- **Maintainability:** ง่ายต่อการ Maintain และ Debug
|
||||
- **Flexibility:** รองรับการเปลี่ยนแปลง Workflow ในอนาคต
|
||||
- **Traceability:** ติดตามประวัติการเปลี่ยนสถานะได้ชัดเจน
|
||||
- **Performance:** ประมวลผล Workflow ได้เร็วและมีประสิทธิภาพ
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Hard-coded Workflow per Document Type
|
||||
|
||||
**แนวทาง:** สร้างตาราง `correspondence_routings`, `rfa_approvals`, `circulation_routings` แยกกัน
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ เข้าใจง่าย straightforward
|
||||
- ✅ Query performance ดี (table-specific indexes)
|
||||
- ✅ Schema ชัดเจนสำหรับแต่ละ type
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Code duplication มาก
|
||||
- ❌ ยากต่อการเพิ่ม Document Type ใหม่
|
||||
- ❌ Inconsistent state management
|
||||
- ❌ ไม่มี Workflow versioning mechanism
|
||||
- ❌ ยากต่อการ reuse common workflows
|
||||
|
||||
### Option 2: Generic Workflow Engine with Hard-coded State Machines
|
||||
|
||||
**แนวทาง:** สร้าง Workflow Engine แต่ Hard-code State Machine ไว้ใน Code
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Centralized workflow logic
|
||||
- ✅ Reusable workflow components
|
||||
- ✅ Better maintainability
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ต้อง Deploy ใหม่ทุกครั้งที่แก้ Workflow
|
||||
- ❌ ไม่ยืดหยุ่นสำหรับ Business Users
|
||||
- ❌ Versioning ยังซับซ้อน
|
||||
|
||||
### Option 3: **DSL-Based Unified Workflow Engine** ⭐ (Selected)
|
||||
|
||||
**แนวทาง:** สร้าง Workflow Engine ที่ใช้ JSON-based DSL (Domain Specific Language) เพื่อ Define Workflows
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Single Source of Truth:** Workflow logic อยู่ใน Database
|
||||
- ✅ **Versioning Support:** เก็บ Workflow Definition versions ได้
|
||||
- ✅ **Runtime Flexibility:** แก้ Workflow ได้โดยไม่ต้อง Deploy
|
||||
- ✅ **Reusability:** Workflow templates สามารถใช้ซ้ำได้
|
||||
- ✅ **Consistency:** State management เป็นมาตรฐานเดียวกัน
|
||||
- ✅ **Audit Trail:** ประวัติครบถ้วนใน `workflow_history`
|
||||
- ✅ **Scalability:** เพิ่ม Document Type ใหม่ได้ง่าย
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Initial development complexity สูง
|
||||
- ❌ ต้องเขียน DSL Parser และ Validator
|
||||
- ❌ Performance overhead เล็กน้อย (parse JSON)
|
||||
- ❌ Learning curve สำหรับทีม
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** Option 3 - DSL-Based Unified Workflow Engine
|
||||
|
||||
### Rationale
|
||||
|
||||
เลือก Unified Workflow Engine เนื่องจาก:
|
||||
|
||||
1. **Long-term Maintainability:** แม้จะมี complexity ในการพัฒนา แต่ในระยะยาวจะลดภาระการ Maintain
|
||||
2. **Business Flexibility:** Business Users สามารถปรับ Workflow ได้ (ผ่าน Admin UI ในอนาคต)
|
||||
3. **Consistency:** สถานะและประวัติเป็นมาตรฐานเดียวกันทุก Document Type
|
||||
4. **Scalability:** เตรียมพร้อมสำหรับ Document Types ใหม่ๆ ในอนาคต
|
||||
5. **Versioning:** รองรับการแก้ไข Workflow โดยไม่กระทบ In-progress documents
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- Workflow Definitions (Templates)
|
||||
CREATE TABLE workflow_definitions (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
version INT NOT NULL,
|
||||
entity_type ENUM('correspondence', 'rfa', 'circulation'),
|
||||
definition JSON NOT NULL, -- DSL Configuration
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY (name, version)
|
||||
);
|
||||
|
||||
-- Workflow Instances (Running Workflows)
|
||||
CREATE TABLE workflow_instances (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
definition_id INT NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INT NOT NULL,
|
||||
current_state VARCHAR(50) NOT NULL,
|
||||
context JSON, -- Runtime data
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (definition_id) REFERENCES workflow_definitions(id)
|
||||
);
|
||||
|
||||
-- Workflow History (Audit Trail)
|
||||
CREATE TABLE workflow_history (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
instance_id INT NOT NULL,
|
||||
from_state VARCHAR(50),
|
||||
to_state VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
actor_id INT NOT NULL,
|
||||
metadata JSON,
|
||||
transitioned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (instance_id) REFERENCES workflow_instances(id),
|
||||
FOREIGN KEY (actor_id) REFERENCES users(user_id)
|
||||
);
|
||||
```
|
||||
|
||||
### DSL Example
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "CORRESPONDENCE_ROUTING",
|
||||
"version": 1,
|
||||
"entity_type": "correspondence",
|
||||
"states": [
|
||||
{
|
||||
"name": "DRAFT",
|
||||
"type": "initial",
|
||||
"allowed_transitions": ["SUBMIT"]
|
||||
},
|
||||
{
|
||||
"name": "SUBMITTED",
|
||||
"type": "intermediate",
|
||||
"allowed_transitions": ["RECEIVE", "RETURN", "CANCEL"]
|
||||
},
|
||||
{
|
||||
"name": "RECEIVED",
|
||||
"type": "intermediate",
|
||||
"allowed_transitions": ["REPLY", "FORWARD", "CLOSE"]
|
||||
},
|
||||
{
|
||||
"name": "CLOSED",
|
||||
"type": "final"
|
||||
}
|
||||
],
|
||||
"transitions": [
|
||||
{
|
||||
"action": "SUBMIT",
|
||||
"from": "DRAFT",
|
||||
"to": "SUBMITTED",
|
||||
"guards": [
|
||||
{
|
||||
"type": "permission",
|
||||
"permission": "correspondence.submit"
|
||||
},
|
||||
{
|
||||
"type": "validation",
|
||||
"rules": ["hasRecipient", "hasAttachment"]
|
||||
}
|
||||
],
|
||||
"effects": [
|
||||
{
|
||||
"type": "notification",
|
||||
"template": "correspondence_submitted",
|
||||
"recipients": ["originator", "assigned_reviewer"]
|
||||
},
|
||||
{
|
||||
"type": "update_entity",
|
||||
"field": "submitted_at",
|
||||
"value": "{{now}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### NestJS Module Structure
|
||||
|
||||
```typescript
|
||||
// workflow-engine.module.ts
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance,
|
||||
WorkflowHistory,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
WorkflowEngineService,
|
||||
WorkflowDefinitionService,
|
||||
WorkflowInstanceService,
|
||||
DslParserService,
|
||||
StateValidator,
|
||||
TransitionExecutor,
|
||||
],
|
||||
exports: [WorkflowEngineService],
|
||||
})
|
||||
export class WorkflowEngineModule {}
|
||||
|
||||
// workflow-engine.service.ts
|
||||
@Injectable()
|
||||
export class WorkflowEngineService {
|
||||
async createInstance(
|
||||
definitionId: number,
|
||||
entityType: string,
|
||||
entityId: number
|
||||
): Promise<WorkflowInstance> {
|
||||
const definition = await this.getActiveDefinition(definitionId);
|
||||
const initialState = this.dslParser.getInitialState(definition.definition);
|
||||
|
||||
return this.instanceRepo.save({
|
||||
definition_id: definitionId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
current_state: initialState,
|
||||
});
|
||||
}
|
||||
|
||||
async executeTransition(
|
||||
instanceId: number,
|
||||
action: string,
|
||||
actorId: number
|
||||
): Promise<void> {
|
||||
const instance = await this.instanceRepo.findOne(instanceId);
|
||||
const definition = await this.definitionRepo.findOne(
|
||||
instance.definition_id
|
||||
);
|
||||
|
||||
// Validate transition
|
||||
const transition = this.stateValidator.validateTransition(
|
||||
definition.definition,
|
||||
instance.current_state,
|
||||
action
|
||||
);
|
||||
|
||||
// Execute guards
|
||||
await this.checkGuards(transition.guards, instance, actorId);
|
||||
|
||||
// Update state
|
||||
await this.transitionExecutor.execute(instance, transition, actorId);
|
||||
|
||||
// Record history
|
||||
await this.recordHistory(instance, transition, actorId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Unified State Management:** สถานะทุก Document Type จัดการโดย Engine เดียว
|
||||
2. ✅ **No Code Changes for Workflow Updates:** แก้ Workflow ผ่าน JSON DSL
|
||||
3. ✅ **Complete Audit Trail:** ประวัติครบถ้วนใน `workflow_history`
|
||||
4. ✅ **Versioning Support:** In-progress documents ใช้ Workflow Version เดิม
|
||||
5. ✅ **Reusable Templates:** สามารถ Clone Workflow Template ได้
|
||||
6. ✅ **Future-proof:** พร้อมสำหรับ Document Types ใหม่
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Initial Complexity:** ต้องสร้าง DSL Parser, Validator, Executor
|
||||
2. ❌ **Learning Curve:** ทีมต้องเรียนรู้ DSL Structure
|
||||
3. ❌ **Performance:** เพิ่ม overhead เล็กน้อยจากการ parse JSON
|
||||
4. ❌ **Debugging:** ยากกว่า Hard-coded logic เล็กน้อย
|
||||
5. ❌ **Testing:** ต้อง Test ทั้ง Engine และ Workflow Definitions
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Complexity:** สร้าง UI Builder สำหรับ Workflow Design ในอนาคต
|
||||
- **Learning Curve:** เขียน Documentation และ Examples ที่ชัดเจน
|
||||
- **Performance:** ใช้ Redis Cache สำหรับ Workflow Definitions
|
||||
- **Debugging:** สร้าง Workflow Visualization Tool
|
||||
- **Testing:** เขียน Comprehensive Unit Tests สำหรับ Engine
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
เป็นไปตาม:
|
||||
|
||||
- [Backend Plan Section 2.4.1](../../docs/2_Backend_Plan_V1_4_5.md) - Unified Workflow Engine
|
||||
- [Requirements 3.6](../01-requirements/03.6-unified-workflow.md) - Unified Workflow Specification
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Workflow DSL จะถูก Validate ด้วย JSON Schema ก่อน Save
|
||||
- Admin UI สำหรับจัดการ Workflow จะพัฒนาใน Phase 2
|
||||
- ต้องมี Migration Tool สำหรับ Workflow Definition Changes
|
||||
- พิจารณาใช้ BPMN 2.0 Notation ในอนาคต (ถ้าต้องการ Visual Workflow Designer)
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-002: Document Numbering Strategy](./ADR-002-document-numbering-strategy.md) - ใช้ Workflow Engine trigger Document Number Generation
|
||||
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md) - Permission Guards ใน Workflow Transitions
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [NestJS State Machine Example](https://docs.nestjs.com/techniques/queues)
|
||||
- [Workflow Patterns](http://www.workflowpatterns.com/)
|
||||
- [JSON Schema Specification](https://json-schema.org/)
|
||||
432
specs/05-decisions/ADR-002-document-numbering-strategy.md
Normal file
432
specs/05-decisions/ADR-002-document-numbering-strategy.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# ADR-002: Document Numbering Strategy
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-11-30
|
||||
**Decision Makers:** Development Team, System Architect
|
||||
**Related Documents:**
|
||||
|
||||
- [System Architecture](../02-architecture/system-architecture.md)
|
||||
- [Document Numbering Requirements](../01-requirements/03.11-document-numbering.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องสร้างเลขที่เอกสารอัตโนมัติสำหรับ Correspondences และ RF
|
||||
|
||||
As โดยเลขที่เอกสารต้อง:
|
||||
|
||||
1. **Unique:** ไม่ซ้ำกันในระบบ
|
||||
2. **Sequential:** เรียงตามลำดับเวลา
|
||||
3. **Meaningful:** มีโครงสร้างที่อ่านเข้าใจได้ (เช่น `TEAM-RFA-STR-2025-0001`)
|
||||
4. **Configurable:** สามารถปรับรูปแบบได้ตาม Project/Organization
|
||||
5. **Concurrent-safe:** ป้องกัน Race Condition เมื่อมีหลาย Request พร้อมกัน
|
||||
|
||||
### Key Challenges
|
||||
|
||||
1. **Race Condition:** เมื่อมี 2+ requests พร้อมกัน อาจได้เลขเดียวกัน
|
||||
2. **Performance:** ต้องรวดเร็วแม้มี concurrent requests
|
||||
3. **Flexibility:** รองรับรูปแบบเลขที่หลากหลาย
|
||||
4. **Discipline Support:** เลขที่ต้องรวม Discipline Code (GEN, STR, ARC)
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Data Integrity:** เลขที่ต้องไม่ซ้ำกันเด็ดขาด
|
||||
- **Performance:** Generate เลขที่ได้เร็ว (< 100ms)
|
||||
- **Scalability:** รองรับ concurrent requests สูง
|
||||
- **Maintainability:** ง่ายต่อการ Config และ Debug
|
||||
- **Flexibility:** รองรับรูปแบบที่หลากหลาย
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Database AUTO_INCREMENT
|
||||
|
||||
**แนวทาง:** ใช้ MySQL AUTO_INCREMENT column
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple implementation
|
||||
- ✅ Database handles uniqueness
|
||||
- ✅ Very fast performance
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่ Configurable (รูปแบบเลขที่ fixed)
|
||||
- ❌ ยากต่อการ Partition by Project/Type/Year
|
||||
- ❌ ไม่รองรับ Custom format (เช่น `TEAM-RFA-2025-0001`)
|
||||
- ❌ Reset ตาม Year ทำได้ยาก
|
||||
|
||||
### Option 2: Application-Level Counter (Single Lock)
|
||||
|
||||
**แนวทาง:** ใช้ Redis INCR สำหรับ Counter
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Fast performance (Redis in-memory)
|
||||
- ✅ Configurable format
|
||||
- ✅ Easy to partition (different Redis keys)
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Single Point of Failure (ถ้า Redis down)
|
||||
- ❌ ไม่มี Persistence ถ้า Redis crash (ถ้าไม่ใช้ AOF/RDB)
|
||||
- ❌ Difficult to audit (ไม่มี history ใน DB)
|
||||
|
||||
### Option 3: **Double-Lock Mechanism (Redis + Database)** ⭐ (Selected)
|
||||
|
||||
**แนวทาง:** ใช้ Redis Distributed Lock + Database Optimistic Locking + Version Column
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Guaranteed Uniqueness:** Double-layer protection
|
||||
- ✅ **Fast Performance:** Redis lock prevents most conflicts
|
||||
- ✅ **Audit Trail:** Counter history in database
|
||||
- ✅ **Configurable Format:** Template-based generation
|
||||
- ✅ **Resilient:** Fallback to DB if Redis issues
|
||||
- ✅ **Partition Support:** Different counters per Project/Type/Discipline/Year
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ More complex implementation
|
||||
- ❌ Slightly slower than pure Redis (but still fast)
|
||||
- ❌ Requires both Redis and DB
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** Option 3 - Double-Lock Mechanism (Redis + Database)
|
||||
|
||||
### Rationale
|
||||
|
||||
เลือก Double-Lock เนื่องจาก:
|
||||
|
||||
1. **Mission-Critical:** เลขที่เอกสารต้องถูกต้อง 100% (ไม่ยอมรับการซ้ำ)
|
||||
2. **Performance + Safety:** Balance ระหว่างความเร็วและความปลอดภัย
|
||||
3. **Auditability:** มี Counter history ใน Database
|
||||
4. **Flexibility:** รองรับ Template-based format
|
||||
5. **Resilience:** ถ้า Redis มีปัญหา ยัง Fallback ไปใช้ DB Lock ได้
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- Format Templates
|
||||
CREATE TABLE document_number_formats (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
project_id INT NOT NULL,
|
||||
correspondence_type_id INT NOT NULL,
|
||||
format_template VARCHAR(255) NOT NULL,
|
||||
-- Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}'
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id),
|
||||
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id),
|
||||
UNIQUE KEY (project_id, correspondence_type_id)
|
||||
);
|
||||
|
||||
-- Counter Table with Optimistic Locking
|
||||
CREATE TABLE document_number_counters (
|
||||
project_id INT NOT NULL,
|
||||
originator_organization_id INT NOT NULL,
|
||||
correspondence_type_id INT NOT NULL,
|
||||
discipline_id INT DEFAULT 0, -- 0 = no discipline
|
||||
current_year INT NOT NULL,
|
||||
last_number INT DEFAULT 0,
|
||||
version INT DEFAULT 0 NOT NULL, -- Version for Optimistic Lock
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (project_id, originator_organization_id, correspondence_type_id, discipline_id, current_year),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id),
|
||||
FOREIGN KEY (originator_organization_id) REFERENCES organizations(id),
|
||||
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id),
|
||||
FOREIGN KEY (discipline_id) REFERENCES disciplines(id)
|
||||
);
|
||||
```
|
||||
|
||||
### NestJS Service Implementation
|
||||
|
||||
```typescript
|
||||
// document-numbering.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import Redlock from 'redlock';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
interface NumberingContext {
|
||||
projectId: number;
|
||||
organizationId: number;
|
||||
typeId: number;
|
||||
disciplineId?: number;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentNumberingService {
|
||||
constructor(
|
||||
@InjectRepository(DocumentNumberCounter)
|
||||
private counterRepo: Repository<DocumentNumberCounter>,
|
||||
@InjectRepository(DocumentNumberFormat)
|
||||
private formatRepo: Repository<DocumentNumberFormat>,
|
||||
private redis: Redis,
|
||||
private redlock: Redlock
|
||||
) {}
|
||||
|
||||
async generateNextNumber(context: NumberingContext): Promise<string> {
|
||||
const year = context.year || new Date().getFullYear();
|
||||
const disciplineId = context.disciplineId || 0;
|
||||
|
||||
// Step 1: Acquire Redis Distributed Lock
|
||||
const lockKey = `doc_num:${context.projectId}:${context.organizationId}:${context.typeId}:${disciplineId}:${year}`;
|
||||
const lock = await this.redlock.acquire([lockKey], 3000); // 3 second TTL
|
||||
|
||||
try {
|
||||
// Step 2: Query current counter with version
|
||||
let counter = await this.counterRepo.findOne({
|
||||
where: {
|
||||
project_id: context.projectId,
|
||||
originator_organization_id: context.organizationId,
|
||||
correspondence_type_id: context.typeId,
|
||||
discipline_id: disciplineId,
|
||||
current_year: year,
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize counter if not exists
|
||||
if (!counter) {
|
||||
counter = this.counterRepo.create({
|
||||
project_id: context.projectId,
|
||||
originator_organization_id: context.organizationId,
|
||||
correspondence_type_id: context.typeId,
|
||||
discipline_id: disciplineId,
|
||||
current_year: year,
|
||||
last_number: 0,
|
||||
version: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const currentVersion = counter.version;
|
||||
const nextNumber = counter.last_number + 1;
|
||||
|
||||
// Step 3: Update counter with Optimistic Lock check
|
||||
const result = await this.counterRepo
|
||||
.createQueryBuilder()
|
||||
.update(DocumentNumberCounter)
|
||||
.set({
|
||||
last_number: nextNumber,
|
||||
version: () => 'version + 1',
|
||||
})
|
||||
.where({
|
||||
project_id: context.projectId,
|
||||
originator_organization_id: context.organizationId,
|
||||
correspondence_type_id: context.typeId,
|
||||
discipline_id: disciplineId,
|
||||
current_year: year,
|
||||
version: currentVersion, // Optimistic lock check
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (result.affected === 0) {
|
||||
throw new Error('Optimistic lock conflict - counter version changed');
|
||||
}
|
||||
|
||||
// Step 4: Generate formatted number
|
||||
const format = await this.getFormat(context.projectId, context.typeId);
|
||||
const formattedNumber = await this.formatNumber(format, {
|
||||
...context,
|
||||
year,
|
||||
sequenceNumber: nextNumber,
|
||||
});
|
||||
|
||||
return formattedNumber;
|
||||
} finally {
|
||||
// Step 5: Release Redis lock
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async formatNumber(
|
||||
format: DocumentNumberFormat,
|
||||
data: any
|
||||
): Promise<string> {
|
||||
let result = format.format_template;
|
||||
|
||||
// Replace tokens
|
||||
const tokens = {
|
||||
'{ORG_CODE}': await this.getOrgCode(data.organizationId),
|
||||
'{TYPE_CODE}': await this.getTypeCode(data.typeId),
|
||||
'{DISCIPLINE_CODE}': await this.getDisciplineCode(data.disciplineId),
|
||||
'{YEAR}': data.year.toString(),
|
||||
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
|
||||
};
|
||||
|
||||
for (const [token, value] of Object.entries(tokens)) {
|
||||
result = result.replace(token, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Algorithm Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Service as Correspondence Service
|
||||
participant Numbering as Numbering Service
|
||||
participant Redis
|
||||
participant DB as MariaDB
|
||||
|
||||
Service->>Numbering: generateNextNumber(context)
|
||||
Numbering->>Redis: ACQUIRE Lock (key)
|
||||
|
||||
alt Lock Acquired
|
||||
Redis-->>Numbering: Lock Success
|
||||
Numbering->>DB: SELECT counter (with version)
|
||||
DB-->>Numbering: current_number, version
|
||||
Numbering->>DB: UPDATE counter SET last_number = X, version = version + 1<br/>WHERE version = old_version
|
||||
|
||||
alt Update Success
|
||||
DB-->>Numbering: Success (1 row affected)
|
||||
Numbering->>Numbering: Format Number
|
||||
Numbering->>Redis: RELEASE Lock
|
||||
Numbering-->>Service: Document Number
|
||||
else Version Conflict
|
||||
DB-->>Numbering: Failed (0 rows affected)
|
||||
Numbering->>Redis: RELEASE Lock
|
||||
Numbering->>Numbering: Retry with Exponential Backoff
|
||||
end
|
||||
else Lock Failed
|
||||
Redis-->>Numbering: Lock Timeout
|
||||
Numbering-->>Service: Error: Unable to acquire lock
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Zero Duplicate Risk:** Double-lock guarantees uniqueness
|
||||
2. ✅ **High Performance:** Redis lock prevents most DB conflicts (< 100ms)
|
||||
3. ✅ **Audit Trail:** All counters stored in database
|
||||
4. ✅ **Template-Based:** Easy to configure different formats
|
||||
5. ✅ **Partition Support:** Separate counters per Project/Type/Discipline/Year
|
||||
6. ✅ **Discipline Integration:** รองรับ Discipline Code ตาม Requirement 6B
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Complexity:** Requires Redis + Database coordination
|
||||
2. ❌ **Dependencies:** Requires both Redis and DB healthy
|
||||
3. ❌ **Retry Logic:** May retry on optimistic lock conflicts
|
||||
4. ❌ **Monitoring:** Need to monitor lock acquisition times
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Redis Dependency:** Use Redis Persistence (AOF) และ Replication
|
||||
- **Complexity:** Encapsulate logic in `DocumentNumberingService`
|
||||
- **Retry:** Exponential backoff with max 3 retries
|
||||
- **Monitoring:** Track lock wait times และ conflict rates
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('DocumentNumberingService - Concurrent Generation', () => {
|
||||
it('should generate unique numbers for 100 concurrent requests', async () => {
|
||||
const context = {
|
||||
projectId: 1,
|
||||
organizationId: 1,
|
||||
typeId: 1,
|
||||
disciplineId: 2, // STR
|
||||
year: 2025,
|
||||
};
|
||||
|
||||
const promises = Array(100)
|
||||
.fill(null)
|
||||
.map(() => service.generateNextNumber(context));
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Check uniqueness
|
||||
const unique = new Set(results);
|
||||
expect(unique.size).toBe(100);
|
||||
|
||||
// Check sequential
|
||||
const numbers = results.map((r) => parseInt(r.split('-').pop()));
|
||||
const sorted = [...numbers].sort((a, b) => a - b);
|
||||
expect(numbers.every((n, i) => sorted.includes(n))).toBe(true);
|
||||
});
|
||||
|
||||
it('should use correct format template', async () => {
|
||||
const number = await service.generateNextNumber({
|
||||
projectId: 1,
|
||||
organizationId: 3, // TEAM
|
||||
typeId: 1, // RFA
|
||||
disciplineId: 2, // STR
|
||||
year: 2025,
|
||||
});
|
||||
|
||||
expect(number).toMatch(/^TEAM-RFA-STR-2025-\d{4}$/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
|
||||
```yaml
|
||||
# Artillery configuration
|
||||
config:
|
||||
target: 'http://localhost:3000'
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 50 # 50 requests/second
|
||||
|
||||
scenarios:
|
||||
- name: 'Generate Document Numbers'
|
||||
flow:
|
||||
- post:
|
||||
url: '/correspondences'
|
||||
json:
|
||||
title: 'Load Test {{ $randomString() }}'
|
||||
project_id: 1
|
||||
type_id: 1
|
||||
discipline_id: 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
เป็นไปตาม:
|
||||
|
||||
- [Backend Plan Section 4.2.10](../../docs/2_Backend_Plan_V1_4_5.md) - DocumentNumberingModule
|
||||
- [Requirements 3.11](../01-requirements/03.11-document-numbering.md) - Document Numbering
|
||||
- [Requirements 6B](../../docs/2_Backend_Plan_V1_4_4.Phase6B.md) - Discipline Support
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-001: Unified Workflow Engine](./ADR-001-unified-workflow-engine.md) - Workflow triggers number generation
|
||||
- [ADR-005: Redis Usage Strategy](./ADR-005-redis-usage-strategy.md) - Redis lock implementation
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Redlock Algorithm](https://redis.io/topics/distlock)
|
||||
- [TypeORM Optimistic Locking](https://typeorm.io/entities#version-column)
|
||||
- [Distributed Lock Patterns](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)
|
||||
505
specs/05-decisions/ADR-003-file-storage-approach.md
Normal file
505
specs/05-decisions/ADR-003-file-storage-approach.md
Normal file
@@ -0,0 +1,505 @@
|
||||
# ADR-003: Two-Phase File Storage Approach
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-11-30
|
||||
**Decision Makers:** Development Team, System Architect
|
||||
**Related Documents:**
|
||||
|
||||
- [System Architecture](../02-architecture/system-architecture.md)
|
||||
- [File Handling Requirements](../01-requirements/03.10-file-handling.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องจัดการ File Uploads สำหรับ Attachments ของเอกสาร (PDF, DWG, DOCX, etc.) โดยต้องรับมือกับปัญหา:
|
||||
|
||||
1. **Orphan Files:** User อัพโหลดไฟล์แล้วไม่ Submit Form → ไฟล์ค้างใน Storage
|
||||
2. **Transaction Integrity:** ถ้า Database Transaction Rollback → ไฟล์ยังอยู่ใน Storage
|
||||
3. **Virus Scanning:** ต้อง Scan ไฟล์ก่อน Save
|
||||
4. **File Validation:** ตรวจสอบ Type, Size, Checksum
|
||||
5. **Storage Organization:** จัดเก็บไฟล์อย่างเป็นระเบียบ
|
||||
|
||||
### Key Challenges
|
||||
|
||||
- **Orphan File Problem:** ไฟล์ที่ไม่เคยถูก Link กับ Document
|
||||
- **Data Consistency:** ต้อง Sync กับ Database Transaction
|
||||
- **Performance:** Upload ต้องเร็ว (ไม่ Block Form Submission)
|
||||
- **Security:** ป้องกัน Malicious Files
|
||||
- **Storage Management:** จำกัด QNAP Storage Space
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Data Integrity:** File และ Database Record ต้อง Consistent
|
||||
- **Security:** ป้องกัน Virus และ Malicious Files
|
||||
- **User Experience:** Upload ต้องรวดเร็ว ไม่ Block UI
|
||||
- **Storage Efficiency:** ไม่เก็บไฟล์ที่ไม่ใช้
|
||||
- **Auditability:** ติดตามประวัติ File Operations
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Direct Upload to Permanent Storage
|
||||
|
||||
**แนวทาง:** อัพโหลดไฟล์ไปยัง Permanent Storage ทันที
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple implementation
|
||||
- ✅ Fast upload (one-step process)
|
||||
- ✅ No intermediate storage
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Orphan files ถ้า user ไม่ submit form
|
||||
- ❌ ยากต่อการ Rollback ถ้า Transaction fail
|
||||
- ❌ ต้อง Manual cleanup orphan files
|
||||
- ❌ Security risk (file available before validation)
|
||||
|
||||
### Option 2: Upload after Form Submission
|
||||
|
||||
**แนวทาง:** Upload ไฟล์หลังจาก Submit Form
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ No orphan files
|
||||
- ✅ Guaranteed consistency
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Slow form submission (wait for upload)
|
||||
- ❌ Poor UX (user waits for all files to upload)
|
||||
- ❌ Transaction timeout risk (large files)
|
||||
- ❌ ไม่ Support progress indication สำหรับแต่ละไฟล์
|
||||
|
||||
### Option 3: **Two-Phase Storage (Temp → Permanent)** ⭐ (Selected)
|
||||
|
||||
**แนวทาง:** Upload ไปยัง Temporary Storage ก่อน → Commit เมื่อ Submit Form สำเร็จ
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Fast Upload:** User upload ได้เลย ไม่ต้องรอ Submit
|
||||
- ✅ **No Orphan Files:** Temp files cleanup automatically
|
||||
- ✅ **Transaction Safe:** Move to permanent only on commit
|
||||
- ✅ **Better UX:** Show progress per file
|
||||
- ✅ **Security:** Scan files before entering system
|
||||
- ✅ **Audit Trail:** Track all file operations
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ More complex implementation
|
||||
- ❌ Need cleanup job for expired temp files
|
||||
- ❌ Extra storage space (temp directory)
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** Option 3 - Two-Phase Storage (Temp → Permanent)
|
||||
|
||||
### Rationale
|
||||
|
||||
เลือก Two-Phase Storage เนื่องจาก:
|
||||
|
||||
1. **Better User Experience:** Upload ไว ไม่ Block Form Submission
|
||||
2. **Data Integrity:** Sync กับ Database Transaction ได้ดี
|
||||
3. **No Orphan Files:** Auto-cleanup ไฟล์ที่ไม่ใช้
|
||||
4. **Security:** Scan และ Validate ก่อน Commit
|
||||
5. **Scalability:** รองรับ Large Files และ Multiple Files
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE attachments (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
stored_filename VARCHAR(255) NOT NULL, -- UUID-based
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
file_size INT NOT NULL,
|
||||
checksum VARCHAR(64) NULL, -- SHA-256
|
||||
|
||||
-- Two-Phase Fields
|
||||
is_temporary BOOLEAN DEFAULT TRUE,
|
||||
temp_id VARCHAR(100) NULL, -- UUID for temp reference
|
||||
expires_at DATETIME NULL, -- Temp file expiration
|
||||
|
||||
uploaded_by_user_id INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (uploaded_by_user_id) REFERENCES users(user_id),
|
||||
INDEX idx_temp_files (is_temporary, expires_at)
|
||||
);
|
||||
```
|
||||
|
||||
### Two-Phase Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as Client
|
||||
participant BE as Backend
|
||||
participant Virus as ClamAV
|
||||
participant TempStorage as Temp Storage
|
||||
participant PermStorage as Permanent Storage
|
||||
participant DB as Database
|
||||
|
||||
Note over User,DB: Phase 1: Upload to Temporary Storage
|
||||
User->>BE: POST /attachments/upload (file)
|
||||
BE->>BE: Validate file type, size
|
||||
BE->>Virus: Scan virus
|
||||
|
||||
alt File is CLEAN
|
||||
Virus-->>BE: CLEAN
|
||||
BE->>BE: Generate temp_id (UUID)
|
||||
BE->>BE: Calculate SHA-256 checksum
|
||||
BE->>TempStorage: Save to temp/{temp_id}
|
||||
BE->>DB: INSERT attachment<br/>(is_temporary=TRUE, expires_at=NOW+24h)
|
||||
BE-->>User: { temp_id, expires_at }
|
||||
else File is INFECTED
|
||||
Virus-->>BE: INFECTED
|
||||
BE-->>User: Error: Virus detected
|
||||
end
|
||||
|
||||
Note over User,DB: Phase 2: Commit to Permanent Storage
|
||||
User->>BE: POST /correspondences<br/>{ temp_file_ids: [temp_id] }
|
||||
BE->>DB: BEGIN Transaction
|
||||
BE->>DB: INSERT correspondence
|
||||
|
||||
loop For each temp_file_id
|
||||
BE->>TempStorage: Read temp file
|
||||
BE->>PermStorage: Move to permanent/{YYYY}/{MM}/{UUID}
|
||||
BE->>DB: UPDATE attachment<br/>(is_temporary=FALSE, file_path=new_path)
|
||||
BE->>DB: INSERT correspondence_attachments
|
||||
BE->>TempStorage: DELETE temp file
|
||||
end
|
||||
|
||||
BE->>DB: COMMIT Transaction
|
||||
BE-->>User: Success
|
||||
|
||||
Note over BE,TempStorage: Cleanup Job (Every 6 hours)
|
||||
BE->>DB: SELECT expired temp files
|
||||
BE->>TempStorage: DELETE expired files
|
||||
BE->>DB: DELETE attachment records
|
||||
```
|
||||
|
||||
### NestJS Service Implementation
|
||||
|
||||
```typescript
|
||||
// file-storage.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class FileStorageService {
|
||||
private readonly TEMP_DIR: string;
|
||||
private readonly PERMANENT_DIR: string;
|
||||
private readonly TEMP_EXPIRY_HOURS = 24;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.TEMP_DIR = this.config.get('STORAGE_PATH') + '/temp';
|
||||
this.PERMANENT_DIR = this.config.get('STORAGE_PATH') + '/permanent';
|
||||
}
|
||||
|
||||
// Phase 1: Upload to Temporary
|
||||
async uploadToTemp(file: Express.Multer.File): Promise<UploadResult> {
|
||||
// 1. Validate file
|
||||
this.validateFile(file);
|
||||
|
||||
// 2. Virus scan
|
||||
await this.virusScan(file);
|
||||
|
||||
// 3. Generate temp ID
|
||||
const tempId = uuidv4();
|
||||
const storedFilename = `${tempId}_${file.originalname}`;
|
||||
const tempPath = path.join(this.TEMP_DIR, storedFilename);
|
||||
|
||||
// 4. Calculate checksum
|
||||
const checksum = await this.calculateChecksum(file.buffer);
|
||||
|
||||
// 5. Save to temp directory
|
||||
await fs.writeFile(tempPath, file.buffer);
|
||||
|
||||
// 6. Create attachment record
|
||||
const attachment = await this.attachmentRepo.save({
|
||||
original_filename: file.originalname,
|
||||
stored_filename: storedFilename,
|
||||
file_path: tempPath,
|
||||
mime_type: file.mimetype,
|
||||
file_size: file.size,
|
||||
checksum,
|
||||
is_temporary: true,
|
||||
temp_id: tempId,
|
||||
expires_at: new Date(Date.now() + this.TEMP_EXPIRY_HOURS * 3600 * 1000),
|
||||
uploaded_by_user_id: this.currentUserId,
|
||||
});
|
||||
|
||||
return {
|
||||
temp_id: tempId,
|
||||
expires_at: attachment.expires_at,
|
||||
filename: file.originalname,
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 2: Commit to Permanent (within Transaction)
|
||||
async commitFiles(
|
||||
tempIds: string[],
|
||||
entityId: number,
|
||||
entityType: string,
|
||||
manager: EntityManager
|
||||
): Promise<Attachment[]> {
|
||||
const attachments = [];
|
||||
|
||||
for (const tempId of tempIds) {
|
||||
// 1. Get temp attachment
|
||||
const tempAttachment = await manager.findOne(Attachment, {
|
||||
where: { temp_id: tempId, is_temporary: true },
|
||||
});
|
||||
|
||||
if (!tempAttachment) {
|
||||
throw new Error(`Temporary file not found: ${tempId}`);
|
||||
}
|
||||
|
||||
// 2. Check expiration
|
||||
if (tempAttachment.expires_at < new Date()) {
|
||||
throw new Error(`Temporary file expired: ${tempId}`);
|
||||
}
|
||||
|
||||
// 3. Generate permanent path
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const permanentDir = path.join(
|
||||
this.PERMANENT_DIR,
|
||||
year.toString(),
|
||||
month
|
||||
);
|
||||
await fs.ensureDir(permanentDir);
|
||||
|
||||
const permanentFilename = `${uuidv4()}_${
|
||||
tempAttachment.original_filename
|
||||
}`;
|
||||
const permanentPath = path.join(permanentDir, permanentFilename);
|
||||
|
||||
// 4. Move file
|
||||
await fs.move(tempAttachment.file_path, permanentPath);
|
||||
|
||||
// 5. Update attachment record
|
||||
await manager.update(
|
||||
Attachment,
|
||||
{ id: tempAttachment.id },
|
||||
{
|
||||
file_path: permanentPath,
|
||||
stored_filename: permanentFilename,
|
||||
is_temporary: false,
|
||||
temp_id: null,
|
||||
expires_at: null,
|
||||
}
|
||||
);
|
||||
|
||||
attachments.push(tempAttachment);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
// Cleanup Job (Cron)
|
||||
@Cron('0 */6 * * *') // Every 6 hours
|
||||
async cleanupExpiredFiles(): Promise<void> {
|
||||
const expiredFiles = await this.attachmentRepo.find({
|
||||
where: {
|
||||
is_temporary: true,
|
||||
expires_at: LessThan(new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
for (const file of expiredFiles) {
|
||||
try {
|
||||
// Delete physical file
|
||||
await fs.remove(file.file_path);
|
||||
|
||||
// Delete DB record
|
||||
await this.attachmentRepo.remove(file);
|
||||
|
||||
this.logger.log(`Cleaned up expired file: ${file.temp_id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to cleanup file: ${file.temp_id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async virusScan(file: Express.Multer.File): Promise<void> {
|
||||
// ClamAV integration
|
||||
const scanner = await this.clamAV.scan(file.buffer);
|
||||
if (scanner.isInfected) {
|
||||
throw new BadRequestException('Virus detected in file');
|
||||
}
|
||||
}
|
||||
|
||||
private validateFile(file: Express.Multer.File): void {
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
const maxSize = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
if (!allowedTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Invalid file type');
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
throw new BadRequestException('File too large (max 50MB)');
|
||||
}
|
||||
}
|
||||
|
||||
private async calculateChecksum(buffer: Buffer): Promise<string> {
|
||||
return createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller Example
|
||||
|
||||
```typescript
|
||||
@Controller('attachments')
|
||||
export class AttachmentController {
|
||||
// Phase 1: Upload
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async upload(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.fileStorage.uploadToTemp(file);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('correspondences')
|
||||
export class CorrespondenceController {
|
||||
// Phase 2: Create with attachments
|
||||
@Post()
|
||||
async create(@Body() dto: CreateCorrespondenceDto) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// 1. Create correspondence
|
||||
const correspondence = await manager.save(Correspondence, {
|
||||
title: dto.title,
|
||||
project_id: dto.project_id,
|
||||
// ...
|
||||
});
|
||||
|
||||
// 2. Commit files (within transaction)
|
||||
if (dto.temp_file_ids?.length > 0) {
|
||||
await this.fileStorage.commitFiles(
|
||||
dto.temp_file_ids,
|
||||
correspondence.id,
|
||||
'correspondence',
|
||||
manager
|
||||
);
|
||||
}
|
||||
|
||||
return correspondence;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Fast Upload UX:** User upload แบบ Async ก่อน Submit
|
||||
2. ✅ **No Orphan Files:** Auto-cleanup ไฟล์ที่หมดอายุ
|
||||
3. ✅ **Transaction Safe:** Rollback ได้สมบูรณ์
|
||||
4. ✅ **Security:** Virus scan ก่อน Commit
|
||||
5. ✅ **Audit Trail:** ติดตาม Upload และ Commit operations
|
||||
6. ✅ **Storage Organization:** จัดเก็บเป็น YYYY/MM structure
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Complexity:** ต้อง Implement 2 phases
|
||||
2. ❌ **Extra Storage:** ต้องมี Temp directory
|
||||
3. ❌ **Cleanup Job:** ต้องรัน Cron job
|
||||
4. ❌ **Edge Cases:** Handle expired files, missing temp files
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Complexity:** Encapsulate ใน `FileStorageService`
|
||||
- **Storage:** Monitor และ Alert ถ้า Temp directory ใหญ่เกินไป
|
||||
- **Cleanup:** Run Cron ทุก 6 ชั่วโมง + Alert ถ้า Fail
|
||||
- **Edge Cases:** Proper error handling และ logging
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### File Validation
|
||||
|
||||
1. **Type Validation:**
|
||||
|
||||
- Check MIME type
|
||||
- Verify Magic Numbers (ไม่ใช่แค่ extension)
|
||||
|
||||
2. **Size Validation:**
|
||||
|
||||
- Max 50MB per file
|
||||
- Total max 500MB per form submission
|
||||
|
||||
3. **Virus Scanning:**
|
||||
|
||||
- ClamAV integration
|
||||
- Scan before saving to temp
|
||||
|
||||
4. **Checksum:**
|
||||
- SHA-256 for integrity verification
|
||||
- Detect file tampering
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Upload Optimization
|
||||
|
||||
- **Streaming:** Use multipart/form-data streaming
|
||||
- **Parallel Uploads:** Client upload multiple files กรณี
|
||||
- **Progress Indication:** Return upload progress for large files
|
||||
- **Chunk Upload:** Support resumable uploads (future)
|
||||
|
||||
### Storage Optimization
|
||||
|
||||
- **Compression:** Consider compressing certain file types
|
||||
- **Deduplication:** Check checksum before storing (future)
|
||||
- **CDN:** Consider CDN for frequently accessed files (future)
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
เป็นไปตาม:
|
||||
|
||||
- [Backend Plan Section 4.2.1](../../docs/2_Backend_Plan_V1_4_5.md) - FileStorageService
|
||||
- [Requirements 3.10](../01-requirements/03.10-file-handling.md) - File Handling
|
||||
- [System Architecture Section 5.2](../02-architecture/system-architecture.md) - File Upload Flow
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-006: Security Best Practices](./ADR-006-security-best-practices.md) - Virus scanning และ file validation
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [ClamAV Documentation](https://docs.clamav.net/)
|
||||
- [Multer Middleware](https://github.com/expressjs/multer)
|
||||
- [File Upload Best Practices](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html)
|
||||
423
specs/05-decisions/ADR-004-rbac-implementation.md
Normal file
423
specs/05-decisions/ADR-004-rbac-implementation.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# ADR-004: RBAC Implementation with 4-Level Scope
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-11-30
|
||||
**Decision Makers:** Development Team, Security Team
|
||||
**Related Documents:**
|
||||
|
||||
- [System Architecture](../02-architecture/system-architecture.md)
|
||||
- [Access Control Requirements](../01-requirements/04-access-control.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องจัดการสิทธิ์การเข้าถึงที่ซับซ้อน:
|
||||
|
||||
- **Multi-Organization:** หลายองค์กรใช้ระบบร่วมกัน แต่ต้องแยกข้อมูล
|
||||
- **Project-Based:** แต่ละ Project มี Contracts แยกกัน
|
||||
- **Hierarchical Permissions:** สิทธิ์ระดับบนครอบคลุมระดับล่าง
|
||||
- **Dynamic Roles:** Role และ Permission ต้องปรับได้โดยไม่ต้อง Deploy
|
||||
|
||||
### Key Requirements
|
||||
|
||||
1. User หนึ่งคนสามารถมีหลาย Roles ในหลาย Scopes
|
||||
2. Permission Inheritance (Global → Organization → Project → Contract)
|
||||
3. Fine-grained Access Control (e.g., "ดู Correspondence ได้เฉพาะ Project A")
|
||||
4. Performance (Check permission ต้องเร็ว < 10ms)
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Security:** ป้องกันการเข้าถึงข้อมูลที่ไม่มีสิทธิ์
|
||||
- **Flexibility:** ปรับ Roles/Permissions ได้ง่าย
|
||||
- **Performance:** Check permission รวดเร็ว
|
||||
- **Usability:** Admin กำหนดสิทธิ์ได้ง่าย
|
||||
- **Scalability:** รองรับ Users/Organizations จำนวนมาก
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Simple Role-Based (No Scope)
|
||||
|
||||
**แนวทาง:** Users มี Roles (Admin, Editor, Viewer) เท่านั้น ไม่มี Scope
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Very simple implementation
|
||||
- ✅ Easy to understand
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่รองรับ Multi-organization
|
||||
- ❌ Superadmin เห็นข้อมูลทุก Organization
|
||||
- ❌ ไม่ยืดหยุ่น
|
||||
|
||||
### Option 2: Organization-Only Scope
|
||||
|
||||
**แนวทาง:** Roles ผูกกับ Organization เท่านั้น
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ แยกข้อมูลระหว่าง Organizations ได้
|
||||
- ✅ Moderate complexity
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่รองรับ Project/Contract level permissions
|
||||
- ❌ User ใน Organization เห็นทุก Project
|
||||
|
||||
### Option 3: **4-Level Hierarchical RBAC** ⭐ (Selected)
|
||||
|
||||
**แนวทาง:** Global → Organization → Project → Contract
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Maximum Flexibility:** ครอบคลุมทุก Use Case
|
||||
- ✅ **Inheritance:** Global Admin เห็นทุกอย่าง
|
||||
- ✅ **Isolation:** Project Manager เห็นแค่ Project ของตน
|
||||
- ✅ **Fine-grained:** Contract Admin จัดการแค่ Contract เดียว
|
||||
- ✅ **Dynamic:** Roles/Permissions configurable
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Complex implementation
|
||||
- ❌ Performance concern (need optimization)
|
||||
- ❌ Learning curve for admins
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** Option 3 - 4-Level Hierarchical RBAC
|
||||
|
||||
### Rationale
|
||||
|
||||
เลือก 4-Level RBAC เนื่องจาก:
|
||||
|
||||
1. **Business Requirements:** Project มีหลาย Contracts ที่ต้องแยกสิทธิ์
|
||||
2. **Future-proof:** รองรับการเติบโตในอนาคต
|
||||
3. **CASL Integration:** ใช้ library ที่รองรับ complex permissions
|
||||
4. **Redis Caching:** แก้ปัญหา Performance ด้วย Cache
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- Roles with Scope
|
||||
CREATE TABLE roles (
|
||||
role_id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
role_name VARCHAR(100) NOT NULL,
|
||||
scope ENUM('Global', 'Organization', 'Project', 'Contract') NOT NULL,
|
||||
description TEXT,
|
||||
is_system BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Permissions
|
||||
CREATE TABLE permissions (
|
||||
permission_id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
permission_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
module VARCHAR(50),
|
||||
scope_level ENUM('GLOBAL', 'ORG', 'PROJECT')
|
||||
);
|
||||
|
||||
-- Role-Permission Mapping
|
||||
CREATE TABLE role_permissions (
|
||||
role_id INT,
|
||||
permission_id INT,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- User Role Assignments with Scope Context
|
||||
CREATE TABLE user_assignments (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
organization_id INT NULL,
|
||||
project_id INT NULL,
|
||||
contract_id INT NULL,
|
||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_scope CHECK (
|
||||
(organization_id IS NOT NULL AND project_id IS NULL AND contract_id IS NULL) OR
|
||||
(organization_id IS NULL AND project_id IS NOT NULL AND contract_id IS NULL) OR
|
||||
(organization_id IS NULL AND project_id IS NULL AND contract_id IS NOT NULL) OR
|
||||
(organization_id IS NULL AND project_id IS NULL AND contract_id IS NULL)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### CASL Ability Rules
|
||||
|
||||
```typescript
|
||||
// ability.factory.ts
|
||||
import { AbilityBuilder, PureAbility } from '@casl/ability';
|
||||
|
||||
export type AppAbility = PureAbility<[string, any]>;
|
||||
|
||||
@Injectable()
|
||||
export class AbilityFactory {
|
||||
async createForUser(user: User): Promise<AppAbility> {
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility);
|
||||
|
||||
// Get user assignments (from cache or DB)
|
||||
const assignments = await this.getUserAssignments(user.user_id);
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const role = await this.getRole(assignment.role_id);
|
||||
const permissions = await this.getRolePermissions(role.role_id);
|
||||
|
||||
for (const permission of permissions) {
|
||||
// permission format: 'correspondence.create', 'project.view'
|
||||
const [subject, action] = permission.permission_name.split('.');
|
||||
|
||||
// Apply scope-based conditions
|
||||
switch (assignment.scope) {
|
||||
case 'Global':
|
||||
can(action, subject);
|
||||
break;
|
||||
|
||||
case 'Organization':
|
||||
can(action, subject, {
|
||||
organization_id: assignment.organization_id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Project':
|
||||
can(action, subject, {
|
||||
project_id: assignment.project_id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Contract':
|
||||
can(action, subject, {
|
||||
contract_id: assignment.contract_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Guard
|
||||
|
||||
```typescript
|
||||
// permission.guard.ts
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private abilityFactory: AbilityFactory,
|
||||
private redis: Redis
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get required permission from decorator
|
||||
const permission = this.reflector.get<string>(
|
||||
'permission',
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
if (!permission) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// Check cache first (30 min TTL)
|
||||
const cacheKey = `user:${user.user_id}:permissions`;
|
||||
let ability = await this.redis.get(cacheKey);
|
||||
|
||||
if (!ability) {
|
||||
ability = await this.abilityFactory.createForUser(user);
|
||||
await this.redis.set(cacheKey, JSON.stringify(ability.rules), 'EX', 1800);
|
||||
}
|
||||
|
||||
const [action, subject] = permission.split('.');
|
||||
const resource = request.params || request.body;
|
||||
|
||||
return ability.can(action, subject, resource);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
@Controller('correspondences')
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
export class CorrespondenceController {
|
||||
@Post()
|
||||
@RequirePermission('correspondence.create')
|
||||
async create(@Body() dto: CreateCorrespondenceDto) {
|
||||
// Only users with create permission can access
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('correspondence.view')
|
||||
async findOne(@Param('id') id: string) {
|
||||
// Check if user has view permission for this project
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permission Checking Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Guard as Permission Guard
|
||||
participant Redis as Redis Cache
|
||||
participant Factory as Ability Factory
|
||||
participant DB as Database
|
||||
|
||||
Client->>Guard: Request with JWT
|
||||
Guard->>Redis: Get user permissions (cache)
|
||||
|
||||
alt Cache Hit
|
||||
Redis-->>Guard: Cached permissions
|
||||
else Cache Miss
|
||||
Guard->>Factory: createForUser(user)
|
||||
Factory->>DB: Get user_assignments
|
||||
Factory->>DB: Get role_permissions
|
||||
Factory->>Factory: Build CASL ability
|
||||
Factory-->>Guard: Ability object
|
||||
Guard->>Redis: Cache permissions (TTL: 30min)
|
||||
end
|
||||
|
||||
Guard->>Guard: Check permission.can(action, subject, context)
|
||||
|
||||
alt Permission Granted
|
||||
Guard-->>Client: Allow access
|
||||
else Permission Denied
|
||||
Guard-->>Client: 403 Forbidden
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4-Level Scope Hierarchy
|
||||
|
||||
```
|
||||
Global (ทั้งระบบ)
|
||||
│
|
||||
├─ Organization (ระดับองค์กร)
|
||||
│ ├─ Project (ระดับโครงการ)
|
||||
│ │ └─ Contract (ระดับสัญญา)
|
||||
│ │
|
||||
│ └─ Project B
|
||||
│ └─ Contract B
|
||||
│
|
||||
└─ Organization 2
|
||||
└─ Project C
|
||||
```
|
||||
|
||||
### Example Assignments
|
||||
|
||||
```typescript
|
||||
// User A: Superadmin (Global)
|
||||
{
|
||||
user_id: 1,
|
||||
role_id: 1, // Superadmin
|
||||
organization_id: null,
|
||||
project_id: null,
|
||||
contract_id: null
|
||||
}
|
||||
// Can access EVERYTHING
|
||||
|
||||
// User B: Document Control in TEAM Organization
|
||||
{
|
||||
user_id: 2,
|
||||
role_id: 3, // Document Control
|
||||
organization_id: 3, // TEAM
|
||||
project_id: null,
|
||||
contract_id: null
|
||||
}
|
||||
// Can manage documents in TEAM organization (all projects)
|
||||
|
||||
// User C: Project Manager for LCBP3
|
||||
{
|
||||
user_id: 3,
|
||||
role_id: 6, // Project Manager
|
||||
organization_id: null,
|
||||
project_id: 1, // LCBP3
|
||||
contract_id: null
|
||||
}
|
||||
// Can manage only LCBP3 project (all contracts within)
|
||||
|
||||
// User D: Contract Admin for Contract-1
|
||||
{
|
||||
user_id: 4,
|
||||
role_id: 7, // Contract Admin
|
||||
organization_id: null,
|
||||
project_id: null,
|
||||
contract_id: 5 // Contract-1
|
||||
}
|
||||
// Can manage only Contract-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Fine-grained Control:** แยกสิทธิ์ได้ละเอียดมาก
|
||||
2. ✅ **Flexible:** User มีหลาย Roles ใน Scopes ต่างกันได้
|
||||
3. ✅ **Inheritance:** Global → Org → Project → Contract
|
||||
4. ✅ **Performant:** Redis cache ทำให้เร็ว (< 10ms)
|
||||
5. ✅ **Auditable:** ทุก Assignment บันทึกใน DB
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Complexity:** ซับซ้อนในการ Setup และ Maintain
|
||||
2. ❌ **Cache Invalidation:** ต้อง Invalidate ถูกต้องเมื่อเปลี่ยน Roles
|
||||
3. ❌ **Learning Curve:** Admin ต้องเข้าใจ Scope hierarchy
|
||||
4. ❌ **Testing:** ต้อง Test ทุก Combination
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Complexity:** สร้าง Admin UI ที่ใช้งานง่าย
|
||||
- **Cache:** Auto-invalidate เมื่อมีการเปลี่ยนแปลง
|
||||
- **Documentation:** เขียน Guide ชัดเจน
|
||||
- **Testing:** Integration tests ครอบคลุม Permissions
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
เป็นไปตาม:
|
||||
|
||||
- [Requirements Section 4](../01-requirements/04-access-control.md) - Access Control
|
||||
- [Backend Plan Section 2 RBAC](../../docs/2_Backend_Plan_V1_4_5.md#rbac)
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005: Redis Usage Strategy](./ADR-005-redis-usage-strategy.md) - Permission caching
|
||||
- [ADR-001: Unified Workflow Engine](./ADR-001-unified-workflow-engine.md) - Workflow permission guards
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [CASL Documentation](https://casl.js.org/v6/en/guide/intro)
|
||||
- [RBAC Best Practices](https://csrc.nist.gov/publications/detail/sp/800-162/final)
|
||||
291
specs/05-decisions/ADR-005-technology-stack.md
Normal file
291
specs/05-decisions/ADR-005-technology-stack.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# ADR-005: Technology Stack Selection
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-11-30
|
||||
**Decision Makers:** Development Team, CTO
|
||||
**Related Documents:**
|
||||
|
||||
- [System Architecture](../02-architecture/system-architecture.md)
|
||||
- [FullStack JS Guidelines](../03-implementation/fullftack-js-v1.5.0.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องเลือก Technology Stack สำหรับพัฒนา Document Management System ที่:
|
||||
|
||||
- รองรับ Multi-user Concurrent Access
|
||||
- จัดการเอกสารซับซ้อนพร้อม Workflow
|
||||
- Deploy บน QNAP Server (ข้อจำกัดด้าน Infrastructure)
|
||||
- พัฒนาโดย Small Team (1-3 developers)
|
||||
- Maintain ได้ในระยะยาว (5+ years)
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Development Speed:** พัฒนาได้เร็ว (6-12 months MVP)
|
||||
- **Maintainability:** Maintain และ Scale ได้ง่าย
|
||||
- **Team Expertise:** ทีมมีประสบการณ์ TypeScript/JavaScript
|
||||
- **Infrastructure Constraints:** Deploy บน QNAP Container Station
|
||||
- **Community Support:** มี Community และ Documentation ดี
|
||||
- **Future-proof:** Technology ยังได้รับการ Support อย่างน้อย 5 ปี
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Traditional Stack (PHP + Laravel + Vue)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Mature ecosystem
|
||||
- ✅ Good documentation
|
||||
- ✅ Familiar to many developers
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Team ไม่มีประสบการณ์ PHP
|
||||
- ❌ Separate language for frontend/backend
|
||||
- ❌ Less TypeScript support
|
||||
|
||||
### Option 2: Java Stack (Spring Boot + React)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Enterprise-grade
|
||||
- ✅ Strong typing
|
||||
- ✅ Excellent tooling
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Team ไม่มีประสบการณ์ Java
|
||||
- ❌ Higher resource usage (QNAP limitation)
|
||||
- ❌ Slower development cycle
|
||||
|
||||
### Option 3: **Full Stack TypeScript (NestJS + Next.js)** ⭐ (Selected)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Single Language:** TypeScript ทั้ง Frontend และ Backend
|
||||
- ✅ **Team Expertise:** ทีมมีประสบการณ์ Node.js/TypeScript
|
||||
- ✅ **Modern Architecture:** Modular, Scalable, Maintainable
|
||||
- ✅ **Rich Ecosystem:** NPM packages มากมาย
|
||||
- ✅ **Fast Development:** Code sharing, Type safety
|
||||
- ✅ **QNAP Compatible:** Docker deployment support
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Runtime Performance ต่ำกว่า Java/Go
|
||||
- ❌ Package.json dependency management complexity
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** Option 3 - Full Stack TypeScript (NestJS + Next.js)
|
||||
|
||||
### Selected Technologies
|
||||
|
||||
#### Backend Stack
|
||||
|
||||
| Component | Technology | Rationale |
|
||||
| :----------------- | :-------------- | :--------------------------------------------- |
|
||||
| **Runtime** | Node.js 20 LTS | Stable, modern features, long-term support |
|
||||
| **Framework** | NestJS | Modular, TypeScript-first, similar to Angular |
|
||||
| **Language** | TypeScript 5.x | Type safety, better DX |
|
||||
| **ORM** | TypeORM | TypeScript support, migrations, repositories |
|
||||
| **Database** | MariaDB 10.11 | JSON support, virtual columns, QNAP compatible |
|
||||
| **Validation** | class-validator | Decorator-based, integrates with NestJS |
|
||||
| **Authentication** | Passport + JWT | Standard, well-supported |
|
||||
| **Authorization** | CASL | Flexible RBAC implementation |
|
||||
| **Documentation** | Swagger/OpenAPI | Auto-generated from decorators |
|
||||
| **Testing** | Jest | Built-in with NestJS |
|
||||
|
||||
#### Frontend Stack
|
||||
|
||||
| Component | Technology | Rationale |
|
||||
| :-------------------- | :------------------ | :------------------------------------- |
|
||||
| **Framework** | Next.js 14+ | App Router, SSR/SSG, React integration |
|
||||
| **UI Library** | React 18 | Industry standard, large ecosystem |
|
||||
| **Language** | TypeScript 5.x | Consistency with backend |
|
||||
| **Styling** | Tailwind CSS | Utility-first, fast development |
|
||||
| **Component Library** | shadcn/ui | Accessible, customizable, TypeScript |
|
||||
| **State Management** | TanStack Query | Server state management |
|
||||
| **Form Handling** | React Hook Form | Performance, ต้ validation with Zod |
|
||||
| **Testing** | Vitest + Playwright | Fast unit tests, reliable E2E |
|
||||
|
||||
#### Infrastructure
|
||||
|
||||
| Component | Technology | Rationale |
|
||||
| :------------------- | :---------------------- | :------------------------------- |
|
||||
| **Containerization** | Docker + Docker Compose | QNAP Container Station |
|
||||
| **Reverse Proxy** | Nginx Proxy Manager | UI-based SSL management |
|
||||
| **Database** | MariaDB 10.11 | Robust, JSON support |
|
||||
| **Cache** | Redis 7 | Session, locks, queue management |
|
||||
| **Search** | Elasticsearch 8 | Full-text search |
|
||||
| **Workflow** | n8n | Visual workflow automation |
|
||||
| **Git** | Gitea | Self-hosted, lightweight |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Backend Architecture: Modular Monolith
|
||||
|
||||
**Chosen:** Modular Monolith (Not Microservices)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- ✅ Easier to develop and deploy initially
|
||||
- ✅ Lower infrastructure overhead (QNAP limitation)
|
||||
- ✅ Simpler debugging and testing
|
||||
- ✅ Can split into microservices later if needed
|
||||
- ✅ Modules communicate via Event Emitters (loosely coupled)
|
||||
|
||||
**Module Structure:**
|
||||
|
||||
```
|
||||
backend/src/
|
||||
├── common/ # Shared utilities
|
||||
├── modules/
|
||||
│ ├── auth/
|
||||
│ ├── user/
|
||||
│ ├── project/
|
||||
│ ├── correspondence/
|
||||
│ ├── rfa/
|
||||
│ ├── workflow-engine/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
### Frontend Architecture: Server-Side Rendering (SSR)
|
||||
|
||||
**Chosen:** Next.js with App Router (SSR + Client Components)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- ✅ Better SEO (if needed in future)
|
||||
- ✅ Faster initial page load
|
||||
- ✅ Flexibility (SSR + CSR)
|
||||
- ✅ Built-in routing and API routes
|
||||
- ✅ Image optimization
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
lcbp3-dms/
|
||||
├── backend/ # NestJS
|
||||
├── frontend/ # Next.js
|
||||
├── docs/ # Documentation
|
||||
├── specs/ # Specifications
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
**Chosen:** Separate repositories (Not Monorepo)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- ✅ ง่ายต่อการ Deploy แยกกัน
|
||||
- ✅ สิทธิ์ Git แยกได้ (Frontend team / Backend team)
|
||||
- ✅ CI/CD pipeline ง่ายกว่า
|
||||
- ❌ Cons: Shared types ต้องจัดการแยก
|
||||
|
||||
---
|
||||
|
||||
## Database Decisions
|
||||
|
||||
### ORM vs Query Builder
|
||||
|
||||
**Chosen:** TypeORM (ORM)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- ✅ Type-safe entities
|
||||
- ✅ Migration management
|
||||
- ✅ Relationship mapping
|
||||
- ✅ Query Builder available when needed
|
||||
- ❌ Cons: Learning curve for complex queries
|
||||
|
||||
### Database Choice
|
||||
|
||||
**Chosen:** MariaDB 10.11 (Not PostgreSQL)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- ✅ QNAP supports MariaDB หนึ่งของโจทย์
|
||||
- ✅ JSON support (MariaDB 10.2+)
|
||||
- ✅ Virtual columns for JSON indexing
|
||||
- ✅ Familiar MySQL syntax
|
||||
- ❌ Cons: ฟีเจอร์บางอย่างไม่เท่า PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## Styling Decision
|
||||
|
||||
### CSS Framework
|
||||
|
||||
**Chosen:** Tailwind CSS (Not Bootstrap, Material-UI)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- ✅ Utility-first, fast development
|
||||
- ✅ Small bundle size (purge unused)
|
||||
- ✅ Highly customizable
|
||||
- ✅ Works well with shadcn/ui
|
||||
- ✅ TypeScript autocomplete support
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Single Language:** TypeScript ลด Context Switching
|
||||
2. ✅ **Code Sharing:** Share types/interfaces ระหว่าง Frontend/Backend
|
||||
3. ✅ **Fast Development:** Modern tooling, hot reload
|
||||
4. ✅ **Type Safety:** Catch errors at compile time
|
||||
5. ✅ **Rich Ecosystem:** NPM packages มากมาย
|
||||
6. ✅ **Good DX:** Excellent developer experience
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Runtime Performance:** ช้ากว่า Compiled languages
|
||||
2. ❌ **Dependency Management:** NPM dependency hell
|
||||
3. ❌ **Memory Usage:** Node.js ใช้ RAM มากกว่า PHP
|
||||
4. ❌ **Package Updates:** Breaking changes บ่อย
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Performance:** ใช้ Redis caching, Database indexing
|
||||
- **Dependencies:** Lock versions, use `pnpm` for deduplication
|
||||
- **Memory:** Monitor และ Optimize, Set Node.js memory limits
|
||||
- **Updates:** Test thoroughly before upgrading major versions
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
เป็นไปตาม:
|
||||
|
||||
- [FullStack JS Guidelines](../03-implementation/fullftack-js-v1.5.0.md)
|
||||
- [Backend Guidelines](../03-implementation/backend-guidelines.md)
|
||||
- [Frontend Guidelines](../03-implementation/frontend-guidelines.md)
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-007: Deployment Strategy](./ADR-007-deployment-strategy.md) - Docker deployment details
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [NestJS Documentation](https://docs.nestjs.com/)
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
- [TypeORM Documentation](https://typeorm.io/)
|
||||
- [State of JavaScript 2024](https://stateofjs.com/)
|
||||
438
specs/05-decisions/ADR-006-redis-caching-strategy.md
Normal file
438
specs/05-decisions/ADR-006-redis-caching-strategy.md
Normal file
@@ -0,0 +1,438 @@
|
||||
# ADR-006: Redis Usage and Caching Strategy
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2025-11-30
|
||||
**Decision Makers:** Development Team, System Architect
|
||||
**Related Documents:**
|
||||
|
||||
- [System Architecture](../02-architecture/system-architecture.md)
|
||||
- [Performance Requirements](../01-requirements/06-non-functional.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้องการ High Performance ในการ:
|
||||
|
||||
- Check Permissions (ทุก Request)
|
||||
- Document Numbering (Concurrent Safe)
|
||||
- Master Data Access (ถูกเรียกบ่อยมาก)
|
||||
- Session Management
|
||||
- Background Job Queue
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Database queries ช้า (แม้มี indexing)
|
||||
- Concurrent access ต้องมี Locking mechanism
|
||||
- Permission checking ต้องเร็ว (< 10ms)
|
||||
- Master data แทบไม่เปลี่ยน แต่ถูก query บ่อย
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Performance:** Response time < 200ms (p90)
|
||||
- **Scalability:** รองรับ 100+ concurrent users
|
||||
- **Consistency:** Data consistency with database
|
||||
- **Reliability:** Cache must not cause data loss
|
||||
- **Cost-Effectiveness:** ใช้ Resource น้อยที่สุด
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: No Caching (Database Only)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple, no cache invalidation
|
||||
- ✅ Always consistent
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Slow permission checks (JOIN tables)
|
||||
- ❌ High DB load
|
||||
- ❌ No distributed locking
|
||||
|
||||
### Option 2: Application-Level In-Memory Cache
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Very fast (local memory)
|
||||
- ✅ No external dependency
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Not shared across instances
|
||||
- ❌ No distributed locking
|
||||
- ❌ Cache invalidation issues
|
||||
|
||||
### Option 3: **Redis as Distributed Cache + Lock** ⭐ (Selected)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Fast:** In-memory, < 1ms access
|
||||
- ✅ **Distributed:** Shared across instances
|
||||
- ✅ **Locking:** Redis locks for concurrency
|
||||
- ✅ **Pub/Sub:** Cache invalidation broadcasting
|
||||
- ✅ **Queue:** BullMQ for background jobs
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ External dependency
|
||||
- ❌ Requires Redis cluster for HA
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** Redis as Distributed Cache + Lock Provider
|
||||
|
||||
---
|
||||
|
||||
## Redis Usage Patterns
|
||||
|
||||
### 1. Distributed Locking (Redlock)
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
- Document Number Generation
|
||||
- Critical Sections
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
const lock = await redlock.acquire([lockKey], 3000); // 3sec TTL
|
||||
try {
|
||||
// Critical section
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- TTL: 2-5 seconds
|
||||
- Retry: Exponential backoff, max 3 retries
|
||||
|
||||
---
|
||||
|
||||
### 2. Permission Caching
|
||||
|
||||
**Cache Structure:**
|
||||
|
||||
```typescript
|
||||
// Key: user:{user_id}:permissions
|
||||
// Value: JSON array of CASL rules
|
||||
// TTL: 30 minutes
|
||||
await redis.set(
|
||||
`user:${userId}:permissions`,
|
||||
JSON.stringify(abilityRules),
|
||||
'EX',
|
||||
1800
|
||||
);
|
||||
```
|
||||
|
||||
**Invalidation Strategy:**
|
||||
|
||||
- Role changed → Invalidate all users with that role
|
||||
- User assignment changed → Invalidate that user
|
||||
- Permission modified → Invalidate all affected roles
|
||||
|
||||
---
|
||||
|
||||
### 3. Master Data Caching
|
||||
|
||||
**Cached Data:**
|
||||
|
||||
- Organizations (TTL: 1 hour)
|
||||
- Projects (TTL: 1 hour)
|
||||
- Correspondence Types (TTL: 24 hours)
|
||||
- RFA Status Codes (TTL: 24 hours)
|
||||
- Roles & Permissions (TTL: 30 minutes)
|
||||
|
||||
**Cache Pattern:**
|
||||
|
||||
```typescript
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
const cacheKey = 'master:organizations';
|
||||
let cached = await redis.get(cacheKey);
|
||||
|
||||
if (!cached) {
|
||||
const organizations = await this.orgRepo.find({ where: { is_active: true } });
|
||||
await redis.set(cacheKey, JSON.stringify(organizations), 'EX', 3600);
|
||||
return organizations;
|
||||
}
|
||||
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
```
|
||||
|
||||
**Invalidation:**
|
||||
|
||||
- On CREATE/UPDATE/DELETE → Invalidate immediately
|
||||
- Publish event to Redis Pub/Sub for multi-instance sync
|
||||
|
||||
---
|
||||
|
||||
### 4. Session Management
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
// Key: session:{session_id}
|
||||
// Value: User session data
|
||||
// TTL: 8 hours
|
||||
interface SessionData {
|
||||
user_id: number;
|
||||
username: string;
|
||||
organization_id: number;
|
||||
last_activity: Date;
|
||||
}
|
||||
```
|
||||
|
||||
**Refresh Strategy:**
|
||||
|
||||
- Update `last_activity` on every request
|
||||
- Extend TTL if activity within last 1 hour
|
||||
|
||||
---
|
||||
|
||||
### 5. Rate Limiting
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
const key = `rate_limit:${userId}:${endpoint}`;
|
||||
const current = await redis.incr(key);
|
||||
if (current === 1) {
|
||||
await redis.expire(key, 3600); // 1 hour window
|
||||
}
|
||||
if (current > limit) {
|
||||
throw new TooManyRequestsException();
|
||||
}
|
||||
```
|
||||
|
||||
**Limits:**
|
||||
|
||||
- File Upload: 50 req/hour per user
|
||||
- Search: 500 req/hour per user
|
||||
- Anonymous: 100 req/hour per IP
|
||||
|
||||
---
|
||||
|
||||
### 6. Background Job Queue (BullMQ)
|
||||
|
||||
**Queues:**
|
||||
|
||||
1. **Email Queue:** Send email notifications
|
||||
2. **Notification Queue:** LINE Notify
|
||||
3. **Indexing Queue:** Elasticsearch indexing
|
||||
4. **Cleanup Queue:** Delete temp files
|
||||
5. **Report Queue:** Generate PDF reports
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```typescript
|
||||
const emailQueue = new Queue('email', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000,
|
||||
},
|
||||
removeOnComplete: 100, // Keep last 100
|
||||
removeOnFail: 500,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache Invalidation Strategy
|
||||
|
||||
### 1. Time-Based Expiration (TTL)
|
||||
|
||||
| Data Type | TTL | Rationale |
|
||||
| :------------- | :--------- | :---------------------------- |
|
||||
| Permissions | 30 minutes | Balance freshness/performance |
|
||||
| Master Data | 1 hour | Rarely changes |
|
||||
| Session | 8 hours | Match JWT expiration |
|
||||
| Search Results | 15 minutes | Data changes frequently |
|
||||
|
||||
### 2. Event-Based Invalidation
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class CacheInvalidationService {
|
||||
async invalidateUserPermissions(userId: number): Promise<void> {
|
||||
await this.redis.del(`user:${userId}:permissions`);
|
||||
|
||||
// Broadcast to other instances
|
||||
await this.redis.publish(
|
||||
'cache:invalidate',
|
||||
JSON.stringify({
|
||||
pattern: 'user:permissions',
|
||||
userId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async invalidateMasterData(entity: string): Promise<void> {
|
||||
await this.redis.del(`master:${entity}`);
|
||||
await this.redis.publish(
|
||||
'cache:invalidate',
|
||||
JSON.stringify({
|
||||
pattern: 'master',
|
||||
entity,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Write-Through Cache
|
||||
|
||||
**For Master Data:**
|
||||
|
||||
```typescript
|
||||
async updateOrganization(id: number, dto: UpdateOrgDto): Promise<Organization> {
|
||||
const org = await this.orgRepo.save({ id, ...dto });
|
||||
|
||||
// Invalidate cache immediately
|
||||
await this.cache.invalidateMasterData('organizations');
|
||||
|
||||
return org;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Redis Configuration
|
||||
|
||||
### Production Setup
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: >
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--maxmemory 2gb
|
||||
--maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
ports:
|
||||
- '6379:6379'
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
**Key Settings:**
|
||||
|
||||
- `appendonly yes`: AOF persistence
|
||||
- `appendfsync everysec`: Write every second (balance performance/durability)
|
||||
- `maxmemory 2gb`: Limit memory usage
|
||||
- `maxmemory-policy allkeys-lru`: Evict least recently used keys
|
||||
|
||||
---
|
||||
|
||||
### High Availability Considerations
|
||||
|
||||
**Future Improvements:**
|
||||
|
||||
1. **Redis Sentinel:** Auto-failover
|
||||
2. **Redis Cluster:** Horizontal scaling
|
||||
3. **Read Replicas:** Offload read queries
|
||||
|
||||
**Current:** Single Redis instance (sufficient for MVP)
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Key Metrics
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class RedisMonitoringService {
|
||||
@Cron('*/5 * * * *') // Every 5 minutes
|
||||
async captureMetrics(): Promise<void> {
|
||||
const info = await this.redis.info();
|
||||
|
||||
// Parse and log metrics
|
||||
metrics.record({
|
||||
'redis.memory.used': parseMemoryUsed(info),
|
||||
'redis.memory.peak': parseMemoryPeak(info),
|
||||
'redis.keyspace.hits': parseHits(info),
|
||||
'redis.keyspace.misses': parseMisses(info),
|
||||
'redis.connections.active': parseConnections(info),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alert Thresholds:**
|
||||
|
||||
- Memory usage > 80%
|
||||
- Hit rate < 70%
|
||||
- Connections > 90% of max
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ **Fast Permission Check:** < 5ms (vs 50ms from DB)
|
||||
2. ✅ **Reduced DB Load:** 70% reduction in queries
|
||||
3. ✅ **Distributed Locking:** No race conditions
|
||||
4. ✅ **Queue Management:** Background jobs reliable
|
||||
5. ✅ **Scalability:** รองรับ Multi-instance deployment
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ **Dependency:** Redis ต้อง Available เสมอ
|
||||
2. ❌ **Memory Limit:** ต้อง Monitor และ Evict
|
||||
3. ❌ **Complexity:** Cache invalidation ซับซ้อน
|
||||
4. ❌ **Data Loss Risk:** ถ้า Redis crash (with AOF mitigates this)
|
||||
|
||||
### Mit Strategies
|
||||
|
||||
- **Dependency:** Health checks + Fallback to DB
|
||||
- **Memory:** Set max memory + LRU eviction policy
|
||||
- **Complexity:** Centralize invalidation logic
|
||||
- **Data Loss:** Enable AOF persistence
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
เป็นไปตาม:
|
||||
|
||||
- [System Architecture Section 3.5](../02-architecture/system-architecture.md#redis)
|
||||
- [Performance Requirements](../01-requirements/06-non-functional.md)
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-002: Document Numbering Strategy](./ADR-002-document-numbering-strategy.md) - Redis locks
|
||||
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md) - Permission caching
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Redis Documentation](https://redis.io/docs/)
|
||||
- [Redlock Algorithm](https://redis.io/topics/distlock)
|
||||
- [BullMQ Documentation](https://docs.bullmq.io/)
|
||||
- [Cache Invalidation Strategies](https://martinfowler.com/bliki/TwoHardThings.html)
|
||||
352
specs/05-decisions/ADR-007-api-design-error-handling.md
Normal file
352
specs/05-decisions/ADR-007-api-design-error-handling.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# ADR-007: API Design & Error Handling Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Backend Team, System Architect
|
||||
**Related Documents:** [Backend Guidelines](../03-implementation/backend-guidelines.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ระบบ LCBP3-DMS ต้องการมาตรฐานการออกแบบ API ที่ชัดเจนและสม่ำเสมอทั้งระบบ รวมถึงกลยุทธ์การจัดการ Error และ Validation ที่เหมาะสม
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **API Consistency:** ทำอย่างไรให้ API response format สม่ำเสมอทั้งระบบ
|
||||
2. **Error Handling:** จัดการ error อย่างไรให้ client เข้าใจและแก้ไขได้
|
||||
3. **Validation:** Validate request อย่างไรให้ครอบคลุมและให้ feedback ที่ดี
|
||||
4. **Status Codes:** ใช้ HTTP status codes อย่างไรให้ถูกต้องและสม่ำเสมอ
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 🎯 **Developer Experience:** Frontend developers ต้องใช้ API ได้ง่าย
|
||||
- 🔒 **Security:** ป้องกัน Information Leakage จาก Error messages
|
||||
- 📊 **Debuggability:** ต้องหา Root cause ของ Error ได้ง่าย
|
||||
- 🌍 **Internationalization:** รองรับภาษาไทยและอังกฤษ
|
||||
- 📝 **Standards Compliance:** ใช้มาตรฐานที่เป็นที่ยอมรับ (REST, JSON:API)
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Standard REST with Custom Error Format
|
||||
|
||||
**รูปแบบ:**
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
{
|
||||
"data": { ... },
|
||||
"meta": { "timestamp": "..." }
|
||||
}
|
||||
|
||||
// Error
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"details": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple และเข้าใจง่าย
|
||||
- ✅ Flexible สำหรับ Custom needs
|
||||
- ✅ ไม่ต้อง Follow spec ที่ซับซ้อน
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่มี Standard specification
|
||||
- ❌ ต้องสื่อสารภายในทีมให้ชัดเจน
|
||||
- ❌ อาจไม่สม่ำเสมอหากไม่ระวัง
|
||||
|
||||
### Option 2: JSON:API Specification
|
||||
|
||||
**รูปแบบ:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
"data": {
|
||||
"type": "correspondences",
|
||||
"id": "1",
|
||||
"attributes": { ... },
|
||||
"relationships": { ... }
|
||||
},
|
||||
"included": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ มาตรฐานที่เป็นที่ยอมรับ
|
||||
- ✅ มี Libraries ช่วย
|
||||
- ✅ รองรับ Relationships ได้ดี
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ซับซ้อนเกินความจำเป็น
|
||||
- ❌ Verbose (ข้อมูลซ้ำซ้อน)
|
||||
- ❌ Learning curve สูง
|
||||
|
||||
### Option 3: GraphQL
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Client เลือกข้อมูลที่ต้องการได้
|
||||
- ✅ ลด Over-fetching/Under-fetching
|
||||
- ✅ Strong typing
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Complexity สูง
|
||||
- ❌ Caching ยาก
|
||||
- ❌ ไม่เหมาะกับ Document-heavy system
|
||||
- ❌ Team ยังไม่มีประสบการณ์
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 1 - Standard REST with Custom Error Format + NestJS Exception Filters**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Simplicity:** ทีมคุ้นเคยกับ REST API และ NestJS มี Built-in support ที่ดี
|
||||
2. **Flexibility:** สามารถปรับแต่งตาม Business needs ได้ง่าย
|
||||
3. **Performance:** Lightweight กว่า JSON:API และ GraphQL
|
||||
4. **Team Capability:** ทีมมีประสบการณ์ REST มากกว่า GraphQL
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Success Response Format
|
||||
|
||||
```typescript
|
||||
// Single resource
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"document_number": "CORR-2024-0001",
|
||||
"subject": "...",
|
||||
...
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
|
||||
// Collection with pagination
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, ... },
|
||||
{ "id": 2, ... }
|
||||
],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 100,
|
||||
"totalPages": 5
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Error Response Format
|
||||
|
||||
```typescript
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed on input data",
|
||||
"statusCode": 400,
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"path": "/api/correspondences",
|
||||
"details": [
|
||||
{
|
||||
"field": "subject",
|
||||
"message": "Subject is required",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. HTTP Status Codes
|
||||
|
||||
| Status | Use Case |
|
||||
| ------------------------- | ------------------------------------------- |
|
||||
| 200 OK | Successful GET, PUT, PATCH |
|
||||
| 201 Created | Successful POST |
|
||||
| 204 No Content | Successful DELETE |
|
||||
| 400 Bad Request | Validation error, Invalid input |
|
||||
| 401 Unauthorized | Missing or invalid JWT token |
|
||||
| 403 Forbidden | Insufficient permissions (RBAC) |
|
||||
| 404 Not Found | Resource not found |
|
||||
| 409 Conflict | Duplicate resource, Business rule violation |
|
||||
| 422 Unprocessable Entity | Business logic error |
|
||||
| 429 Too Many Requests | Rate limit exceeded |
|
||||
| 500 Internal Server Error | Unexpected server error |
|
||||
|
||||
### 4. Global Exception Filter
|
||||
|
||||
```typescript
|
||||
// File: backend/src/common/filters/global-exception.filter.ts
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = 'INTERNAL_SERVER_ERROR';
|
||||
let message = 'An unexpected error occurred';
|
||||
let details = null;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'object') {
|
||||
code = (exceptionResponse as any).error || exception.name;
|
||||
message = (exceptionResponse as any).message || exception.message;
|
||||
details = (exceptionResponse as any).details;
|
||||
} else {
|
||||
message = exceptionResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Log error (but don't expose internal details to client)
|
||||
console.error('Exception:', exception);
|
||||
|
||||
response.status(status).json({
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
...(details && { details }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Custom Business Exception
|
||||
|
||||
```typescript
|
||||
// File: backend/src/common/exceptions/business.exception.ts
|
||||
export class BusinessException extends HttpException {
|
||||
constructor(message: string, code: string = 'BUSINESS_ERROR') {
|
||||
super(
|
||||
{
|
||||
error: code,
|
||||
message,
|
||||
},
|
||||
HttpStatus.UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
throw new BusinessException(
|
||||
'Cannot approve correspondence in current status',
|
||||
'INVALID_WORKFLOW_TRANSITION'
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Validation Pipe Configuration
|
||||
|
||||
```typescript
|
||||
// File: backend/src/main.ts
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true, // Strip properties not in DTO
|
||||
forbidNonWhitelisted: true, // Throw error if unknown properties
|
||||
transform: true, // Auto-transform payloads to DTO instances
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
exceptionFactory: (errors) => {
|
||||
const details = errors.map((error) => ({
|
||||
field: error.property,
|
||||
message: Object.values(error.constraints || {}).join(', '),
|
||||
value: error.value,
|
||||
}));
|
||||
|
||||
return new HttpException(
|
||||
{
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
details,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Consistency:** API responses มีรูปแบบสม่ำเสมอทั้งระบบ
|
||||
2. ✅ **Developer Friendly:** Frontend developers ใช้งาน API ได้ง่าย
|
||||
3. ✅ **Debuggability:** Error messages ให้ข้อมูลเพียงพอสำหรับ Debug
|
||||
4. ✅ **Security:** ไม่เปิดเผย Internal error details ให้ Client
|
||||
5. ✅ **Maintainability:** ใช้ NestJS built-in features ทำให้ Maintain ง่าย
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **No Standard Spec:** ไม่ใช่ Standard เช่น JSON:API จึงต้องเขียน Documentation ชัดเจน
|
||||
2. ❌ **Manual Documentation:** ต้อง Document API response format เอง
|
||||
3. ❌ **Learning Curve:** Team members ใหม่ต้องเรียนรู้ Error code conventions
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Documentation:** ใช้ Swagger/OpenAPI เพื่อ Auto-generate API docs
|
||||
- **Code Generation:** Generate TypeScript interfaces สำหรับ Frontend จาก DTOs
|
||||
- **Error Code Registry:** มี Centralized list ของ Error codes พร้อมคำอธิบาย
|
||||
- **Testing:** เขียน Integration tests เพื่อ Validate response formats
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ NestJS
|
||||
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md) - Error 403 Forbidden
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [NestJS Exception Filters](https://docs.nestjs.com/exception-filters)
|
||||
- [HTTP Status Codes](https://httpstatuses.com/)
|
||||
- [REST API Best Practices](https://restfulapi.net/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2025-06-01
|
||||
388
specs/05-decisions/ADR-008-email-notification-strategy.md
Normal file
388
specs/05-decisions/ADR-008-email-notification-strategy.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# ADR-008: Email & Notification Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Backend Team, System Architect
|
||||
**Related Documents:** [Backend Guidelines](../03-implementation/backend-guidelines.md), [TASK-BE-011](../06-tasks/TASK-BE-011-notification-audit.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ระบบ LCBP3-DMS ต้องการส่งการแจ้งเตือนให้ผู้ใช้งานผ่านหลายช่องทาง (Email, LINE Notify, In-App) เมื่อมี Events สำคัญเกิดขึ้น เช่น Correspondence ได้รับการอนุมัติ, RFA ถูก Review, Workflow เปลี่ยนสถานะ
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Multi-Channel:** รองรับหลายช่องทางการแจ้งเตือน (Email, LINE, In-app)
|
||||
2. **Reliability:** ทำอย่างไรให้การส่ง Email ไม่ Block main request
|
||||
3. **Retry Logic:** จัดการ Email delivery failures อย่างไร
|
||||
4. **Template Management:** จัดการ Email templates อย่างไรให้ Maintainable
|
||||
5. **User Preferences:** ให้ User เลือก Channel ที่ต้องการได้อย่างไร
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- ⚡ **Performance:** ส่ง Email ต้องไม่ทำให้ API Response ช้า
|
||||
- 🔄 **Reliability:** Email ส่งไม่สำเร็จต้อง Retry ได้
|
||||
- 🎨 **Branding:** Email template ต้องดูเป็นมืออาชีพ
|
||||
- 🛠️ **Maintainability:** แก้ไข Template ได้ง่าย
|
||||
- 📱 **Multi-Channel:** รองรับ Email, LINE, In-app notification
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Sync Email Sending (ส่งทันที ใน Request)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
await this.emailService.sendEmail({ to, subject, body });
|
||||
return { success: true };
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple implementation
|
||||
- ✅ ง่ายต่อการ Debug
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Block API response (slow)
|
||||
- ❌ หาก SMTP server down จะ Timeout
|
||||
- ❌ ไม่มี Retry mechanism
|
||||
|
||||
### Option 2: Async with Event Emitter (NestJS EventEmitter)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
this.eventEmitter.emit('correspondence.approved', { correspondenceId });
|
||||
// Return immediately
|
||||
return { success: true };
|
||||
|
||||
// Listener
|
||||
@OnEvent('correspondence.approved')
|
||||
async handleApproved(payload) {
|
||||
await this.emailService.sendEmail(...);
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Non-blocking (async)
|
||||
- ✅ Decoupled
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่มี Retry หาก Event listener fail
|
||||
- ❌ Lost jobs หาก Server restart
|
||||
|
||||
### Option 3: Message Queue (BullMQ + Redis)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
await this.emailQueue.add('send-email', {
|
||||
to,
|
||||
subject,
|
||||
template,
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Non-blocking (async)
|
||||
- ✅ Persistent (Store in Redis)
|
||||
- ✅ Built-in Retry mechanism
|
||||
- ✅ Job monitoring & management
|
||||
- ✅ Scalable (Multiple workers)
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Requires Redis infrastructure
|
||||
- ❌ More complex setup
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 3 - Message Queue (BullMQ + Redis)**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Performance:** ไม่ Block API response, ส่ง Email แบบ Async
|
||||
2. **Reliability:** Persistent jobs ใน Redis, มี Retry mechanism
|
||||
3. **Scalability:** สามารถ Scale workers แยกได้
|
||||
4. **Monitoring:** ดู Job status, Failed jobs ได้
|
||||
5. **Infrastructure:** Redis มีอยู่แล้วสำหรับ Locking และ Caching (ADR-006)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Email Queue Setup
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/notification/notification.module.ts
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'email',
|
||||
connection: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT),
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'line-notify',
|
||||
}),
|
||||
],
|
||||
providers: [NotificationService, EmailProcessor, LineNotifyProcessor],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
```
|
||||
|
||||
### 2. Queue Email Job
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/notification/notification.service.ts
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(
|
||||
@InjectQueue('email') private emailQueue: Queue,
|
||||
@InjectQueue('line-notify') private lineQueue: Queue
|
||||
) {}
|
||||
|
||||
async sendEmailNotification(dto: SendEmailDto): Promise<void> {
|
||||
await this.emailQueue.add(
|
||||
'send-email',
|
||||
{
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
template: dto.template, // e.g., 'correspondence-approved'
|
||||
context: dto.context, // Template variables
|
||||
},
|
||||
{
|
||||
attempts: 3, // Retry 3 times
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000, // Start with 5s delay
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 24 * 3600, // Keep completed jobs for 24h
|
||||
},
|
||||
removeOnFail: false, // Keep failed jobs for debugging
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async sendLineNotification(dto: SendLineDto): Promise<void> {
|
||||
await this.lineQueue.add('send-line', {
|
||||
token: dto.token,
|
||||
message: dto.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Email Processor (Worker)
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/notification/processors/email.processor.ts
|
||||
import { Processor, Process } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import * as handlebars from 'handlebars';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
@Processor('email')
|
||||
export class EmailProcessor {
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor() {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Process('send-email')
|
||||
async handleSendEmail(job: Job) {
|
||||
const { to, subject, template, context } = job.data;
|
||||
|
||||
try {
|
||||
// Load and compile template
|
||||
const templatePath = `./templates/emails/${template}.hbs`;
|
||||
const templateSource = await fs.readFile(templatePath, 'utf-8');
|
||||
const compiledTemplate = handlebars.compile(templateSource);
|
||||
const html = compiledTemplate(context);
|
||||
|
||||
// Send email
|
||||
const info = await this.transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
console.log('Email sent:', info.messageId);
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
throw error; // Will trigger retry
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Email Template (Handlebars)
|
||||
|
||||
```handlebars
|
||||
<!-- File: backend/templates/emails/correspondence-approved.hbs -->
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; } .container { max-width: 600px;
|
||||
margin: 0 auto; padding: 20px; } .header { background: #007bff; color:
|
||||
white; padding: 20px; } .content { padding: 20px; } .button { background:
|
||||
#007bff; color: white; padding: 10px 20px; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='container'>
|
||||
<div class='header'>
|
||||
<h1>Correspondence Approved</h1>
|
||||
</div>
|
||||
<div class='content'>
|
||||
<p>สวัสดีคุณ {{userName}},</p>
|
||||
<p>เอกสาร <strong>{{documentNumber}}</strong> ได้รับการอนุมัติแล้ว</p>
|
||||
<p><strong>Subject:</strong> {{subject}}</p>
|
||||
<p><strong>Approved by:</strong> {{approver}}</p>
|
||||
<p><strong>Date:</strong> {{approvedDate}}</p>
|
||||
<p>
|
||||
<a href='{{documentUrl}}' class='button'>ดูเอกสาร</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 5. Workflow Event → Email Notification
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/workflow/workflow.service.ts
|
||||
async executeTransition(workflowId: number, action: string) {
|
||||
// ... Execute transition logic
|
||||
|
||||
// Send notifications
|
||||
await this.notificationService.notifyWorkflowTransition(
|
||||
workflowId,
|
||||
action,
|
||||
currentUserId,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// File: backend/src/modules/notification/notification.service.ts
|
||||
async notifyWorkflowTransition(
|
||||
workflowId: number,
|
||||
action: string,
|
||||
actorId: number,
|
||||
) {
|
||||
// Get users to notify
|
||||
const users = await this.getRelevantUsers(workflowId);
|
||||
|
||||
for (const user of users) {
|
||||
// In-app notification
|
||||
await this.createNotification({
|
||||
user_id: user.user_id,
|
||||
type: 'workflow_transition',
|
||||
title: `${action} completed`,
|
||||
message: `Workflow ${workflowId} has been ${action}`,
|
||||
link: `/workflows/${workflowId}`,
|
||||
});
|
||||
|
||||
// Email (if enabled)
|
||||
if (user.email_notifications_enabled) {
|
||||
await this.sendEmailNotification({
|
||||
to: user.email,
|
||||
subject: `Workflow Update: ${action}`,
|
||||
template: 'workflow-transition',
|
||||
context: {
|
||||
userName: user.first_name,
|
||||
action,
|
||||
workflowId,
|
||||
documentUrl: `${process.env.FRONTEND_URL}/workflows/${workflowId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// LINE Notify (if enabled)
|
||||
if (user.line_notify_token) {
|
||||
await this.sendLineNotification({
|
||||
token: user.line_notify_token,
|
||||
message: `[LCBP3-DMS] Workflow ${workflowId}: ${action}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Performance:** API responses ไม่ถูก Block โดยการส่ง Email
|
||||
2. ✅ **Reliability:** Jobs ถูกเก็บใน Redis ไม่สูญหายหาก Server restart
|
||||
3. ✅ **Retry:** Automatic retry สำหรับ Failed jobs
|
||||
4. ✅ **Monitoring:** ดู Job status, Failed jobs ผ่าน Bull Board
|
||||
5. ✅ **Scalability:** เพิ่ม Email workers ได้ตามต้องการ
|
||||
6. ✅ **Multi-Channel:** รองรับ Email, LINE, In-app notification
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Delayed Delivery:** Email ส่งแบบ Async อาจมี Delay เล็กน้อย
|
||||
2. ❌ **Dependency on Redis:** หาก Redis down ก็ส่ง Email ไม่ได้
|
||||
3. ❌ **Template Management:** ต้อง Maintain Handlebars templates แยก
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Redis Monitoring:** ตั้ง Alert หาก Redis down
|
||||
- **Template Versioning:** เก็บ Email templates ใน Git
|
||||
- **Fallback:** หาก Redis ล้ม อาจ Fallback เป็น Sync sending ชั่วคราว
|
||||
- **Testing:** ใช้ Mailtrap/MailHog สำหรับ Testing ใน Development
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-006: Redis Caching Strategy](./ADR-006-redis-caching-strategy.md) - ใช้ Redis สำหรับ Queue
|
||||
- [TASK-BE-011: Notification & Audit](../06-tasks/TASK-BE-011-notification-audit.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [BullMQ Documentation](https://docs.bullmq.io/)
|
||||
- [Nodemailer Documentation](https://nodemailer.com/)
|
||||
- [Handlebars Documentation](https://handlebarsjs.com/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2025-06-01
|
||||
383
specs/05-decisions/ADR-009-database-migration-strategy.md
Normal file
383
specs/05-decisions/ADR-009-database-migration-strategy.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# ADR-009: Database Migration & Deployment Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Backend Team, DevOps Team, System Architect
|
||||
**Related Documents:** [TASK-BE-001](../06-tasks/TASK-BE-001-database-migrations.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ระบบ LCBP3-DMS ต้องการกลยุทธ์การจัดการ Database Schema และ Data migrations ที่ปลอดภัยและเชื่อถือได้ เพื่อให้ Deploy ใหม่ได้โดยไม่ทำให้ Production data เสียหาย
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Schema Evolution:** จัดการการเปลี่ยนแปลง Schema ใน Production อย่างไร
|
||||
2. **Zero-Downtime:** Deploy โดยไม่ต้อง Downtime ระบบได้หรือไม่
|
||||
3. **Rollback:** หาก Migration ล้มเหลว จะ Rollback อย่างไร
|
||||
4. **Data Safety:** ป้องกัน Data loss จาก Migration errors อย่างไร
|
||||
5. **Team Collaboration:** หลายคน Develop พร้อมกัน จัดการ Migration conflicts อย่างไร
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 🔒 **Data Safety:** ป้องกัน Data loss เป็นอันดับแรก
|
||||
- ⚡ **Zero Downtime:** Deploy ได้โดยไม่ต้อง Stop service
|
||||
- 🔄 **Reversibility:** สามารถ Rollback ได้ถ้า Migration ล้ม
|
||||
- 👥 **Team Collaboration:** หลายคน Work พร้อมกัน ไม่ Conflict
|
||||
- 📊 **Auditability:** ต้องรู้ว่า Schema เป็น Version ไหน
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Synchronize Schema (TypeORM synchronize: true)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
TypeOrmModule.forRoot({
|
||||
synchronize: true, // Auto-generate schema from entities
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ ง่ายที่สุด ไม่ต้องเขียน Migration
|
||||
- ✅ เหมาะสำหรับ Development
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ **อันตราย** ใน Production (อาจ Drop columns/tables)
|
||||
- ❌ ไม่มี Version control
|
||||
- ❌ ไม่มี Rollback
|
||||
- ❌ ไม่เหมาะสำหรับ Production
|
||||
|
||||
### Option 2: Manual SQL Scripts
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- เขียน SQL scripts ด้วยมือ
|
||||
- Execute โดย DBA
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Full control
|
||||
- ✅ Review ได้ละเอียด
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Manual process (Error-prone)
|
||||
- ❌ ไม่มี Automation
|
||||
- ❌ ลืม Run migration ได้
|
||||
- ❌ Tracking ยาก
|
||||
|
||||
### Option 3: TypeORM Migrations (Automated + Version Controlled)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```bash
|
||||
npm run migration:generate -- MigrationName
|
||||
npm run migration:run
|
||||
npm run migration:revert
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Version controlled (Git)
|
||||
- ✅ Automatic tracking (`migrations` table)
|
||||
- ✅ Rollback support
|
||||
- ✅ Generated from Entity changes
|
||||
- ✅ CI/CD integration
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ต้องเขียน Migration files
|
||||
- ❌ Requires discipline
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 3 - TypeORM Migrations + Blue-Green Deployment Strategy**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Safety:** Migrations มี Version control และ Rollback mechanism
|
||||
2. **Automation:** Run migrations auto ใน CI/CD pipeline
|
||||
3. **Tracking:** ดู Migration history ได้จาก `migrations` table
|
||||
4. **Team Collaboration:** Merge migrations ใน Git เหมือน Code
|
||||
5. **Zero Downtime:** ใช้ Blue-Green deployment สำหรับ Breaking changes
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. TypeORM Configuration
|
||||
|
||||
```typescript
|
||||
// File: backend/src/config/database.config.ts
|
||||
export default {
|
||||
type: 'mariadb',
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT),
|
||||
username: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
entities: ['dist/**/*.entity.js'],
|
||||
migrations: ['dist/migrations/*.js'],
|
||||
migrationsTableName: 'migrations',
|
||||
synchronize: false, // NEVER true in production
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Migration Workflow
|
||||
|
||||
```bash
|
||||
# 1. Create new entity or modify existing
|
||||
# 2. Generate migration
|
||||
npm run migration:generate -- -n AddDisciplineIdToCorrespondences
|
||||
|
||||
# Output: src/migrations/1234567890-AddDisciplineIdToCorrespondences.ts
|
||||
|
||||
# 3. Review generated migration
|
||||
# 4. Test migration locally
|
||||
npm run migration:run
|
||||
|
||||
# 5. Test rollback
|
||||
npm run migration:revert
|
||||
|
||||
# 6. Commit to Git
|
||||
git add src/migrations/
|
||||
git commit -m "feat: add discipline_id to correspondences"
|
||||
```
|
||||
|
||||
### 3. Migration File Example
|
||||
|
||||
```typescript
|
||||
// File: backend/src/migrations/1234567890-AddDisciplineIdToCorrespondences.ts
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
TableColumn,
|
||||
TableForeignKey,
|
||||
} from 'typeorm';
|
||||
|
||||
export class AddDisciplineIdToCorrespondences1234567890
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Add column
|
||||
await queryRunner.addColumn(
|
||||
'correspondences',
|
||||
new TableColumn({
|
||||
name: 'discipline_id',
|
||||
type: 'int',
|
||||
isNullable: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Add foreign key
|
||||
await queryRunner.createForeignKey(
|
||||
'correspondences',
|
||||
new TableForeignKey({
|
||||
columnNames: ['discipline_id'],
|
||||
referencedTableName: 'disciplines',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
);
|
||||
|
||||
// Add index
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX idx_correspondences_discipline_id ON correspondences(discipline_id)'
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Reverse order: index → FK → column
|
||||
await queryRunner.query(
|
||||
'DROP INDEX idx_correspondences_discipline_id ON correspondences'
|
||||
);
|
||||
|
||||
const table = await queryRunner.getTable('correspondences');
|
||||
const foreignKey = table.foreignKeys.find(
|
||||
(fk) => fk.columnNames.indexOf('discipline_id') !== -1
|
||||
);
|
||||
await queryRunner.dropForeignKey('correspondences', foreignKey);
|
||||
|
||||
await queryRunner.dropColumn('correspondences', 'discipline_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. CI/CD Pipeline Integration
|
||||
|
||||
```yaml
|
||||
# File: .github/workflows/deploy.yml
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run migrations
|
||||
run: npm run migration:run
|
||||
env:
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_PORT: ${{ secrets.DB_PORT }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASS: ${{ secrets.DB_PASS }}
|
||||
DB_NAME: ${{ secrets.DB_NAME }}
|
||||
|
||||
- name: Deploy application
|
||||
run: |
|
||||
# Deploy to server
|
||||
pm2 restart app
|
||||
```
|
||||
|
||||
### 5. Zero-Downtime Migration Strategy
|
||||
|
||||
**กรณี Non-Breaking Changes (เพิ่ม Column ใหม่):**
|
||||
|
||||
```bash
|
||||
# Step 1: Add nullable column (Old code still works)
|
||||
ALTER TABLE correspondences ADD COLUMN discipline_id INT NULL;
|
||||
|
||||
# Step 2: Deploy new code (Can use new column)
|
||||
pm2 restart app
|
||||
|
||||
# Step 3: (Optional) Backfill data if needed
|
||||
UPDATE correspondences SET discipline_id = X WHERE ...;
|
||||
```
|
||||
|
||||
**กรณี Breaking Changes (ลบ Column, เปลี่ยนชนิด):**
|
||||
|
||||
**Blue-Green Deployment:**
|
||||
|
||||
```bash
|
||||
# Step 1: Deploy "Green" (New version) พร้อม Migration
|
||||
# - Database supports ทั้ง old + new schema
|
||||
# - Run migration: Add new column, Keep old column
|
||||
|
||||
# Step 2: Route traffic to "Green"
|
||||
# - Load balancer switches to new version
|
||||
|
||||
# Step 3: Verify "Green" works
|
||||
# - Monitor errors, metrics
|
||||
|
||||
# Step 4: (After 24h) Cleanup old schema
|
||||
# - Run migration: Drop old column
|
||||
# - Shutdown "Blue" (Old version)
|
||||
```
|
||||
|
||||
### 6. Migration Testing
|
||||
|
||||
```typescript
|
||||
// File: backend/test/migrations/migration.spec.ts
|
||||
describe('Migrations', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataSource = await createTestDataSource();
|
||||
});
|
||||
|
||||
it('should run all migrations successfully', async () => {
|
||||
await dataSource.runMigrations();
|
||||
|
||||
// Verify tables exist
|
||||
const tables = await dataSource.query('SHOW TABLES');
|
||||
expect(tables).toContainEqual(
|
||||
expect.objectContaining({ Tables_in_lcbp3: 'correspondences' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should rollback all migrations successfully', async () => {
|
||||
await dataSource.runMigrations();
|
||||
await dataSource.undoLastMigration();
|
||||
|
||||
// Verify rollback worked
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Version Control:** Migrations อยู่ใน Git มี History
|
||||
2. ✅ **Automation:** CI/CD run migrations automatically
|
||||
3. ✅ **Rollback:** สามารถ Revert migration ได้
|
||||
4. ✅ **Audit Trail:** ดู Migration history ใน `migrations` table
|
||||
5. ✅ **Zero Downtime:** สามารถ Deploy โดยไม่ Downtime (Blue-Green)
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Discipline Required:** ต้อง Review migrations ก่อน Merge
|
||||
2. ❌ **Complex Rollbacks:** Breaking changes ยาก Rollback
|
||||
3. ❌ **Migration Conflicts:** หลายคน Develop อาจ Conflict (แก้ด้วย Rebase)
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Code Review:** Review migrations เหมือน Code
|
||||
- **Testing:** Test migrations ใน Staging ก่อน Production
|
||||
- **Backup:** Backup database ก่อน Run migration ใน Production
|
||||
- **Monitoring:** Monitor migration execution time และ Errors
|
||||
- **Documentation:** Document Breaking changes ชัดเจน
|
||||
|
||||
---
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
### DO:
|
||||
|
||||
- ✅ Test migrations ใน Development และ Staging
|
||||
- ✅ Backup database ก่อน Production migration
|
||||
- ✅ ใช้ Transactions (TypeORM มีอัตโนมัติ)
|
||||
- ✅ เขียน `down()` migration สำหรับ Rollback
|
||||
- ✅ ใช้ Nullable columns สำหรับ Non-breaking changes
|
||||
|
||||
### DON'T:
|
||||
|
||||
- ❌ Run `synchronize: true` ใน Production
|
||||
- ❌ ลบ Column/Table โดยไม่ Deploy code ก่อน
|
||||
- ❌ เปลี่ยน Data type โดยตรง (ใช้ New column แทน)
|
||||
- ❌ Hardcode Values ใน Migration (ใช้ Environment variables)
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ TypeORM
|
||||
- [TASK-BE-001: Database Migrations](../06-tasks/TASK-BE-001-database-migrations.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [TypeORM Migrations](https://typeorm.io/migrations)
|
||||
- [Blue-Green Deployment](https://martinfowler.com/bliki/BlueGreenDeployment.html)
|
||||
- [Zero-Downtime Migrations](https://www.braintreepayments.com/blog/safe-operations-for-high-volume-postgresql/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2025-06-01
|
||||
464
specs/05-decisions/ADR-010-logging-monitoring-strategy.md
Normal file
464
specs/05-decisions/ADR-010-logging-monitoring-strategy.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# ADR-010: Logging & Monitoring Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Backend Team, DevOps Team
|
||||
**Related Documents:** [Backend Guidelines](../03-implementation/backend-guidelines.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ระบบ LCBP3-DMS ต้องการ Logging และ Monitoring ที่ดีเพื่อ:
|
||||
|
||||
- Debug ปัญหาใน Production
|
||||
- ติดตาม Performance metrics
|
||||
- Audit trail สำหรับ Security และ Compliance
|
||||
- Alert เมื่อมี Errors หรือ Anomalies
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Structured Logging:** บันทึก Logs ในรูปแบบที่ค้นหาและวิเคราะห์ได้ง่าย
|
||||
2. **Log Levels:** กำหนด Log levels ที่เหมาะสมสำหรับแต่ละสถานการณ์
|
||||
3. **Performance Monitoring:** ติดตาม Response time, Database queries, Memory usage
|
||||
4. **Error Tracking:** ติดตาม Errors และ Exceptions อย่างเป็นระบบ
|
||||
5. **Centralized Logging:** รวม Logs จากหลาย Services ไว้ที่เดียว
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 🔍 **Debuggability:** หา Root cause ของปัญหาได้เร็ว
|
||||
- 📊 **Performance Insights:** ดู Metrics และ Bottlenecks
|
||||
- 🚨 **Alerting:** แจ้งเตือนเมื่อมีปัญหา
|
||||
- 📈 **Scalability:** รองรับ High-volume logs
|
||||
- 💰 **Cost:** ไม่ต้องลงทุนมากในช่วงเริ่มต้น
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Console.log (Built-in)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple, ไม่ต้อง Setup
|
||||
- ✅ ไม่มีค่าใช้จ่าย
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่มี Structure
|
||||
- ❌ ไม่มี Log levels
|
||||
- ❌ ไม่มี Log rotation
|
||||
- ❌ ยากต่อการ Search/Filter
|
||||
|
||||
### Option 2: Winston (Structured Logging Library)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Structured logs (JSON format)
|
||||
- ✅ Multiple transports (File, Console, HTTP)
|
||||
- ✅ Log levels (error, warn, info, debug)
|
||||
- ✅ Log rotation
|
||||
- ✅ Mature library
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ต้อง Configure transports
|
||||
- ❌ Performance overhead (minimal)
|
||||
|
||||
### Option 3: Full Observability Stack (ELK/Datadog/New Relic)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Complete solution (Logs + Metrics + APM)
|
||||
- ✅ Powerful query และ Visualization
|
||||
- ✅ Built-in Alerting
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ **ค่าใช้จ่ายสูง**
|
||||
- ❌ Complex setup
|
||||
- ❌ Overkill สำหรับ MVP
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 2 (Winston) + Docker Logging + Future ELK Stack**
|
||||
|
||||
### Rationale
|
||||
|
||||
**Phase 1 (MVP):** Winston with File/Console outputs
|
||||
|
||||
- ✅ เพียงพอสำหรับ MVP
|
||||
- ✅ Structured logs พร้อมสำหรับ ELK ในอนาคต
|
||||
- ✅ ไม่มีค่าใช้จ่ายเพิ่ม
|
||||
|
||||
**Phase 2 (Production Scale):** Add ELK Stack (Elasticsearch, Logstash, Kibana)
|
||||
|
||||
- ✅ Centralized logging
|
||||
- ✅ Search และ Visualization
|
||||
- ✅ Open-source (ไม่มี Vendor lock-in)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Winston Configuration
|
||||
|
||||
```typescript
|
||||
// File: backend/src/config/logger.config.ts
|
||||
import * as winston from 'winston';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: {
|
||||
service: 'lcbp3-dms-backend',
|
||||
environment: process.env.NODE_ENV,
|
||||
},
|
||||
transports: [
|
||||
// Console output (for Development)
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
return `${timestamp} [${level}]: ${message} ${
|
||||
Object.keys(meta).length ? JSON.stringify(meta) : ''
|
||||
}`;
|
||||
})
|
||||
),
|
||||
}),
|
||||
|
||||
// File output (for Production)
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error',
|
||||
maxsize: 10485760, // 10MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/combined.log',
|
||||
maxsize: 10485760,
|
||||
maxFiles: 10,
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 2. NestJS Logger Integration
|
||||
|
||||
```typescript
|
||||
// File: backend/src/main.ts
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { logger as winstonLogger } from './config/logger.config';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: new WinstonLogger(winstonLogger),
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Custom Winston Logger for NestJS
|
||||
|
||||
```typescript
|
||||
// File: backend/src/common/logger/winston.logger.ts
|
||||
import { LoggerService } from '@nestjs/common';
|
||||
import { Logger as WinstonLoggerType } from 'winston';
|
||||
|
||||
export class WinstonLogger implements LoggerService {
|
||||
constructor(private readonly logger: WinstonLoggerType) {}
|
||||
|
||||
log(message: string, context?: string) {
|
||||
this.logger.info(message, { context });
|
||||
}
|
||||
|
||||
error(message: string, trace?: string, context?: string) {
|
||||
this.logger.error(message, { trace, context });
|
||||
}
|
||||
|
||||
warn(message: string, context?: string) {
|
||||
this.logger.warn(message, { context });
|
||||
}
|
||||
|
||||
debug(message: string, context?: string) {
|
||||
this.logger.debug(message, { context });
|
||||
}
|
||||
|
||||
verbose(message: string, context?: string) {
|
||||
this.logger.verbose(message, { context });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Request Logging Middleware
|
||||
|
||||
```typescript
|
||||
// File: backend/src/common/middleware/request-logger.middleware.ts
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from 'src/config/logger.config';
|
||||
|
||||
@Injectable()
|
||||
export class RequestLoggerMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
userAgent: req.headers['user-agent'],
|
||||
ip: req.ip,
|
||||
userId: (req as any).user?.user_id,
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Database Query Logging
|
||||
|
||||
```typescript
|
||||
// File: backend/src/config/database.config.ts
|
||||
export default {
|
||||
// ...
|
||||
logging:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'all'
|
||||
: ['error', 'warn', 'schema'],
|
||||
logger: 'advanced-console',
|
||||
maxQueryExecutionTime: 1000, // Warn if query > 1s
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Error Logging in Exception Filter
|
||||
|
||||
```typescript
|
||||
// File: backend/src/common/filters/global-exception.filter.ts
|
||||
import { logger } from 'src/config/logger.config';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
// ... get status, message
|
||||
|
||||
// Log error
|
||||
logger.error('Exception occurred', {
|
||||
error: exception,
|
||||
statusCode: status,
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
userId: request.user?.user_id,
|
||||
stack: exception instanceof Error ? exception.stack : null,
|
||||
});
|
||||
|
||||
// Send response to client
|
||||
response.status(status).json({ ... });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Log Levels Usage
|
||||
|
||||
```typescript
|
||||
// ERROR: จับ Exceptions และ Errors
|
||||
logger.error('Failed to create correspondence', { error, userId, documentId });
|
||||
|
||||
// WARN: สถานการณ์ผิดปกติ แต่ไม่ Error
|
||||
logger.warn('Document numbering retry attempt 2/3', { template, counter });
|
||||
|
||||
// INFO: Business events สำคัญ
|
||||
logger.info('Correspondence approved', { documentId, approvedBy });
|
||||
|
||||
// DEBUG: ข้อมูลละเอียดสำหรับ Development
|
||||
logger.debug('Workflow transition guard check', { workflowId, guardResult });
|
||||
|
||||
// VERBOSE: ข้อมูลละเอียดมากๆ
|
||||
logger.verbose('Cache hit', { key, ttl });
|
||||
```
|
||||
|
||||
### 8. Performance Monitoring
|
||||
|
||||
```typescript
|
||||
// File: backend/src/common/interceptors/performance.interceptor.ts
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { logger } from 'src/config/logger.config';
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const start = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (duration > 1000) {
|
||||
logger.warn('Slow request detected', {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Log Format Example
|
||||
|
||||
### Development (Console)
|
||||
|
||||
```
|
||||
2024-01-01 10:30:15 [info]: Correspondence approved { documentId: 123, approvedBy: 5 }
|
||||
2024-01-01 10:30:16 [error]: Failed to send email { error: 'SMTP timeout', userId: 5 }
|
||||
```
|
||||
|
||||
### Production (JSON File)
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-01T10:30:15.123Z",
|
||||
"level": "info",
|
||||
"message": "Correspondence approved",
|
||||
"service": "lcbp3-dms-backend",
|
||||
"environment": "production",
|
||||
"documentId": 123,
|
||||
"approvedBy": 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future: ELK Stack Integration
|
||||
|
||||
**Phase 2 Setup:**
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
ports:
|
||||
- '9200:9200'
|
||||
|
||||
logstash:
|
||||
image: docker.elastic.co/logstash/logstash:8.11.0
|
||||
volumes:
|
||||
- ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf
|
||||
depends_on:
|
||||
- elasticsearch
|
||||
|
||||
kibana:
|
||||
image: docker.elastic.co/kibana/kibana:8.11.0
|
||||
ports:
|
||||
- '5601:5601'
|
||||
depends_on:
|
||||
- elasticsearch
|
||||
```
|
||||
|
||||
**Winston transport to Logstash:**
|
||||
|
||||
```typescript
|
||||
import { LogstashTransport } from 'winston-logstash';
|
||||
|
||||
logger.add(
|
||||
new LogstashTransport({
|
||||
host: process.env.LOGSTASH_HOST,
|
||||
port: parseInt(process.env.LOGSTASH_PORT),
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Structured Logs:** ค้นหาและวิเคราะห์ได้ง่าย
|
||||
2. ✅ **Performance Insights:** ดู Slow requests ได้
|
||||
3. ✅ **Error Tracking:** ติดตาม Errors พร้อม Context
|
||||
4. ✅ **Scalable:** พร้อมสำหรับ ELK Stack ในอนาคต
|
||||
5. ✅ **Cost Effective:** ไม่มีค่าใช้จ่ายในช่วง MVP
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Manual Log Search:** ใน Phase 1 ต้องค้นหา Logs ใน Files
|
||||
2. ❌ **No Centralized Dashboard:** ต้องรอ Phase 2 (ELK)
|
||||
3. ❌ **Log Rotation Management:** ต้อง Monitor disk space
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Docker Logging Driver:** ใช้ Docker log driver สำหรับ Log rotation
|
||||
- **Log Aggregation:** ใช้ `docker logs` รวม Logs จากหลาย Containers
|
||||
- **Monitoring:** Set up Disk space alerts
|
||||
|
||||
---
|
||||
|
||||
## Logging Best Practices
|
||||
|
||||
### DO:
|
||||
|
||||
- ✅ Log ทุก HTTP requests พร้อม Response time
|
||||
- ✅ Log Business events สำคัญ (Approved, Rejected, Created)
|
||||
- ✅ Log Errors พร้อม Stack trace และ Context
|
||||
- ✅ ใช้ Structured logging (JSON format)
|
||||
|
||||
### DON'T:
|
||||
|
||||
- ❌ Log Sensitive data (Passwords, Tokens)
|
||||
- ❌ Log ทุก Database query ใน Production
|
||||
- ❌ Log Large payloads (> 1KB) ทั้งหมด
|
||||
- ❌ ใช้ `console.log` แทน Logger
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-007: API Design & Error Handling](./ADR-007-api-design-error-handling.md)
|
||||
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Winston Documentation](https://github.com/winstonjs/winston)
|
||||
- [NestJS Logging](https://docs.nestjs.com/techniques/logger)
|
||||
- [ELK Stack](https://www.elastic.co/elastic-stack)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2025-06-01
|
||||
399
specs/05-decisions/ADR-011-nextjs-app-router.md
Normal file
399
specs/05-decisions/ADR-011-nextjs-app-router.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# ADR-011: Next.js App Router & Routing Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Frontend Team, System Architect
|
||||
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Next.js มี 2 รูปแบบ Router หลัก: Pages Router (เก่า) และ App Router (ใหม่ใน Next.js 13+) ต้องเลือกว่าจะใช้แบบไหนสำหรับ LCBP3-DMS
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Routing Architecture:** ใช้ Pages Router หรือ App Router
|
||||
2. **Server vs Client Components:** จัดการ Data Fetching อย่างไร
|
||||
3. **Layout System:** จัดการ Shared Layouts อย่างไร
|
||||
4. **Performance:** ทำอย่างไรให้ Initial Load เร็ว
|
||||
5. **SEO:** ต้องการ SEO หรือไม่ (Dashboard ไม่ต้องการ)
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 🚀 **Performance:** Initial load time และ Navigation speed
|
||||
- 🎯 **Developer Experience:** ง่ายต่อการพัฒนาและบำรุงรักษา
|
||||
- 📦 **Code Organization:** โครงสร้างโค้ดชัดเจน
|
||||
- 🔄 **Future-Proof:** พร้อมสำหรับ Next.js รุ่นถัดไป
|
||||
- 🎨 **Layout Flexibility:** จัดการ Nested Layouts ได้ง่าย
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Pages Router (Traditional)
|
||||
|
||||
**โครงสร้าง:**
|
||||
|
||||
```
|
||||
pages/
|
||||
├── _app.tsx
|
||||
├── _document.tsx
|
||||
├── index.tsx
|
||||
├── correspondences/
|
||||
│ ├── index.tsx
|
||||
│ └── [id].tsx
|
||||
└── api/
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Mature และ Stable
|
||||
- ✅ Documentation ครบถ้วน
|
||||
- ✅ Community ใหญ่
|
||||
- ✅ ทีมคุ้นเคยแล้ว
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ไม่รองรับ Server Components
|
||||
- ❌ Layout System ซับซ้อน (ต้องใช้ HOC)
|
||||
- ❌ Data Fetching ไม่ทันสมัย
|
||||
- ❌ Not recommended for new projects
|
||||
|
||||
### Option 2: App Router (New - Recommended)
|
||||
|
||||
**โครงสร้าง:**
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout
|
||||
├── page.tsx # Home page
|
||||
├── correspondences/
|
||||
│ ├── layout.tsx # Nested layout
|
||||
│ ├── page.tsx # List page
|
||||
│ └── [id]/
|
||||
│ └── page.tsx # Detail page
|
||||
└── (auth)/
|
||||
├── layout.tsx
|
||||
└── login/
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Server Components (Better performance)
|
||||
- ✅ Built-in Layout System
|
||||
- ✅ Streaming & Suspense support
|
||||
- ✅ Better Data Fetching patterns
|
||||
- ✅ Recommended by Next.js team
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Newer (less community resources)
|
||||
- ❌ Learning curve สำหรับทีม
|
||||
- ❌ Some libraries ยังไม่รองรับ
|
||||
|
||||
### Option 3: Hybrid Approach
|
||||
|
||||
ใช้ App Router + Pages Router พร้อมกัน
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Gradual migration
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ เพิ่มความซับซ้อน
|
||||
- ❌ Confusing สำหรับทีม
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 2 - App Router**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Future-Proof:** Next.js แนะนำให้ใช้ App Router สำหรับโปรเจกต์ใหม่
|
||||
2. **Performance:** Server Components ช่วยลด JavaScript bundle size
|
||||
3. **Better DX:** Layout System สะดวกกว่า
|
||||
4. **Server Actions:** รองรับ Form submissions โดยไม่ต้องสร้าง API routes
|
||||
5. **Learning Investment:** Team จะได้ Skill ที่ทันสมัย
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Folder Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── (public)/ # Public routes (no auth)
|
||||
│ ├── layout.tsx
|
||||
│ └── login/
|
||||
│ └── page.tsx
|
||||
│
|
||||
├── (dashboard)/ # Protected routes
|
||||
│ ├── layout.tsx # Dashboard layout with sidebar
|
||||
│ ├── page.tsx # Dashboard home
|
||||
│ │
|
||||
│ ├── correspondences/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── page.tsx # List
|
||||
│ │ ├── new/
|
||||
│ │ │ └── page.tsx # Create
|
||||
│ │ └── [id]/
|
||||
│ │ ├── page.tsx # Detail
|
||||
│ │ └── edit/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── rfas/
|
||||
│ ├── drawings/
|
||||
│ └── settings/
|
||||
│
|
||||
├── api/ # API route handlers (minimal)
|
||||
│ └── auth/
|
||||
│ └── [...nextauth]/
|
||||
│ └── route.ts
|
||||
│
|
||||
├── layout.tsx # Root layout
|
||||
└── page.tsx # Root redirect
|
||||
```
|
||||
|
||||
### 2. Root Layout
|
||||
|
||||
```typescript
|
||||
// File: app/layout.tsx
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'LCBP3-DMS',
|
||||
description: 'Document Management System for Laem Chabang Port Phase 3',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="th">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Dashboard Layout (with Sidebar)
|
||||
|
||||
```typescript
|
||||
// File: app/(dashboard)/layout.tsx
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getServerSession } from 'next-auth';
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Server-side auth check
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Server Component (Data Fetching)
|
||||
|
||||
```typescript
|
||||
// File: app/(dashboard)/correspondences/page.tsx
|
||||
import { CorrespondenceList } from '@/components/correspondences/list';
|
||||
import { getCorrespondences } from '@/lib/api/correspondences';
|
||||
|
||||
export default async function CorrespondencesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { page?: string; status?: string };
|
||||
}) {
|
||||
// Fetch data on server
|
||||
const correspondences = await getCorrespondences({
|
||||
page: parseInt(searchParams.page || '1'),
|
||||
status: searchParams.status,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Correspondences</h1>
|
||||
<CorrespondenceList data={correspondences} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Client Component (Interactive)
|
||||
|
||||
```typescript
|
||||
// File: components/correspondences/list.tsx
|
||||
'use client'; // Client Component
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Correspondence } from '@/types';
|
||||
|
||||
export function CorrespondenceList({ data }: { data: Correspondence[] }) {
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = data.filter((item) =>
|
||||
item.subject.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="border p-2 mb-4"
|
||||
/>
|
||||
<div>
|
||||
{filtered.map((item) => (
|
||||
<div key={item.id}>{item.subject}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Loading States
|
||||
|
||||
```typescript
|
||||
// File: app/(dashboard)/correspondences/loading.tsx
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-64 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Error Handling
|
||||
|
||||
```typescript
|
||||
// File: app/(dashboard)/correspondences/error.tsx
|
||||
'use client';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold text-red-600">Something went wrong!</h2>
|
||||
<p className="text-gray-600">{error.message}</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Routing Patterns
|
||||
|
||||
### Route Groups (Organization)
|
||||
|
||||
```
|
||||
(public)/ # Public pages
|
||||
(dashboard)/ # Protected dashboard
|
||||
(auth)/ # Auth-related pages
|
||||
```
|
||||
|
||||
### Dynamic Routes
|
||||
|
||||
```
|
||||
[id]/ # Dynamic segment (e.g., /correspondences/123)
|
||||
[...slug]/ # Catch-all (e.g., /docs/a/b/c)
|
||||
```
|
||||
|
||||
### Parallel Routes & Intercepting Routes
|
||||
|
||||
```
|
||||
@modal/ # Parallel route for modals
|
||||
(.)/ # Intercept same level
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Better Performance:** Server Components ลด Client JavaScript
|
||||
2. ✅ **SEO-Friendly:** Server-side rendering out of the box
|
||||
3. ✅ **Simpler Layouts:** Nested layouts ทำได้ง่าย
|
||||
4. ✅ **Streaming:** Progressive rendering with Suspense
|
||||
5. ✅ **Future-Proof:** ทิศทางของ Next.js และ React
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Learning Curve:** ทีมต้องเรียนรู้ Server Components
|
||||
2. ❌ **Limited Libraries:** บาง Libraries ยังไม่รองรับ Server Components
|
||||
3. ❌ **Debugging:** ยากกว่า Pages Router เล็กน้อย
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Training:** จัด Workshop เรื่อง App Router และ Server Components
|
||||
- **Documentation:** เขียน Internal docs สำหรับ Patterns ที่ใช้
|
||||
- **Code Review:** Review code ให้ใช้ Server/Client Components ถูกต้อง
|
||||
- **Gradual Adoption:** เริ่มจาก Simple pages ก่อน
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ Next.js
|
||||
- [ADR-012: UI Component Library](./ADR-012-ui-component-library.md) - Shadcn/UI
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
|
||||
- [React Server Components](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2026-06-01
|
||||
428
specs/05-decisions/ADR-012-ui-component-library.md
Normal file
428
specs/05-decisions/ADR-012-ui-component-library.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# ADR-012: UI Component Library Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Frontend Team, UX Designer
|
||||
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md), [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ต้องการ UI Component Library สำหรับสร้าง User Interface ที่สวยงาม สม่ำเสมอ และ Accessible
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Component Library:** ใช้ Library สำเร็จรูป หรือสร้างเอง
|
||||
2. **Customization:** ปรับแต่งได้ง่ายเพียงใด
|
||||
3. **Accessibility:** รองรับ ARIA และ Keyboard navigation
|
||||
4. **Bundle Size:** ขนาดไฟล์ไม่ใหญ่เกินไป
|
||||
5. **Developer Experience:** ใช้งานง่าย Documentation ครบ
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 🎨 **Design Consistency:** UI สม่ำเสมอทั้งระบบ
|
||||
- ♿ **Accessibility:** รองรับ WCAG 2.1 AA
|
||||
- 🎯 **Customization:** ปรับแต่งได้ตามต้องการ
|
||||
- 📦 **Bundle Size:** เล็กและ Tree-shakeable
|
||||
- ⚡ **Performance:** Render เร็ว
|
||||
- 🛠️ **DX:** Developer Experience ดี
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Material-UI (MUI)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Component ครบชุด
|
||||
- ✅ Documentation ดี
|
||||
- ✅ Community ใหญ่
|
||||
- ✅ Built-in theming
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Bundle size ใหญ่
|
||||
- ❌ Design opinionated (Material Design)
|
||||
- ❌ Customization ยาก
|
||||
- ❌ Performance overhead
|
||||
|
||||
### Option 2: Ant Design
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Component ครบ (เน้น Enterprise)
|
||||
- ✅ i18n support ดี
|
||||
- ✅ Form components ครบ
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Bundle size ใหญ่มาก
|
||||
- ❌ Chinese-centric design
|
||||
- ❌ Customization จำกัด
|
||||
- ❌ TypeScript support ไม่ดีเท่าไร
|
||||
|
||||
### Option 3: Chakra UI
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Accessibility ดี
|
||||
- ✅ Customization ง่าย
|
||||
- ✅ TypeScript first
|
||||
- ✅ Dark mode built-in
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Bundle size ค่อนข้างใหญ่
|
||||
- ❌ CSS-in-JS overhead
|
||||
- ❌ Performance issues with many components
|
||||
|
||||
### Option 4: Headless UI + Tailwind CSS
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Full control over styling
|
||||
- ✅ Lightweight
|
||||
- ✅ Accessibility ดี
|
||||
- ✅ No styling overhead
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ต้องเขียน styles เอง
|
||||
- ❌ Component library น้อย
|
||||
- ❌ ใช้เวลาพัฒนานาน
|
||||
|
||||
### Option 5: Shadcn/UI + Tailwind CSS
|
||||
|
||||
**วิธีการ:** Copy components ที่ต้องการไปยัง Project
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Full ownership:** Components เป็นของเรา ไม่ใช่ dependency
|
||||
- ✅ **Highly customizable:** แก้ไขได้เต็มที่
|
||||
- ✅ **Accessibility:** ใช้ Radix UI Primitives
|
||||
- ✅ **Bundle size:** เฉพาะที่ใช้เท่านั้น
|
||||
- ✅ **Tailwind CSS:** Utility-first ง่ายต่อการ maintain
|
||||
- ✅ **TypeScript:** Type-safe
|
||||
- ✅ **Beautiful defaults:** Design ดูทันสมัย
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ต้อง Copy components เอง
|
||||
- ❌ Update ต้องทำด้วยตัวเอง
|
||||
- ❌ ไม่มี `npm install` แบบ Library
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 5 - Shadcn/UI + Tailwind CSS**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Ownership:** เป็นเจ้าของ Code 100% ปรับแต่งได้อย่างเต็มที่
|
||||
2. **Bundle Size:** เล็กที่สุด (เฉพาะที่ใช้)
|
||||
3. **Accessibility:** ใช้ Radix UI primitives ที่ทดสอบแล้ว
|
||||
4. **Customization:** แก้ไขได้ตามต้องการ ไม่ติด Framework
|
||||
5. **Tailwind CSS:** ทีมคุ้นเคยและใช้อยู่แล้ว
|
||||
6. **Modern Design:** ดูสวยงามและทันสมัย
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Setup Shadcn/UI
|
||||
|
||||
```bash
|
||||
# Initialize shadcn/ui
|
||||
npx shadcn-ui@latest init
|
||||
|
||||
# Select options:
|
||||
# - TypeScript: Yes
|
||||
# - Style: Default
|
||||
# - Base color: Slate
|
||||
# - CSS variables: Yes
|
||||
```
|
||||
|
||||
```typescript
|
||||
// File: components.json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Components
|
||||
|
||||
```bash
|
||||
# Add specific components
|
||||
npx shadcn-ui@latest add button
|
||||
npx shadcn-ui@latest add input
|
||||
npx shadcn-ui@latest add card
|
||||
npx shadcn-ui@latest add dialog
|
||||
npx shadcn-ui@latest add dropdown-menu
|
||||
npx shadcn-ui@latest add table
|
||||
|
||||
# Components will be copied to components/ui/
|
||||
```
|
||||
|
||||
### 3. Component Usage
|
||||
|
||||
```typescript
|
||||
// File: app/correspondences/page.tsx
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export default function CorrespondencesPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Input placeholder="Search..." className="max-w-sm" />
|
||||
<Button>Create New</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-bold">Correspondences</h2>
|
||||
{/* Content */}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Customize Components
|
||||
|
||||
```typescript
|
||||
// File: components/ui/button.tsx
|
||||
// สามารถแก้ไขได้เต็มที่เพราะเป็น Code ของเรา
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
// Add custom variant
|
||||
success: 'bg-green-600 text-white hover:bg-green-700',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
```
|
||||
|
||||
### 5. Theming with CSS Variables
|
||||
|
||||
```css
|
||||
/* File: app/globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
/* ... more colors */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
/* ... dark mode colors */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Component Composition
|
||||
|
||||
```typescript
|
||||
// File: components/correspondence/card.tsx
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function CorrespondenceCard({ correspondence }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle>{correspondence.subject}</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
correspondence.status === 'APPROVED' ? 'success' : 'default'
|
||||
}
|
||||
>
|
||||
{correspondence.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{correspondence.description}
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
View
|
||||
</Button>
|
||||
<Button size="sm">Edit</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
### Core Components (มีอยู่ใน Shadcn/UI)
|
||||
|
||||
**Forms:**
|
||||
|
||||
- Button
|
||||
- Input
|
||||
- Textarea
|
||||
- Select
|
||||
- Checkbox
|
||||
- Radio Group
|
||||
- Switch
|
||||
- Slider
|
||||
- Label
|
||||
|
||||
**Data Display:**
|
||||
|
||||
- Table
|
||||
- Card
|
||||
- Badge
|
||||
- Avatar
|
||||
- Separator
|
||||
|
||||
**Feedback:**
|
||||
|
||||
- Alert
|
||||
- Dialog
|
||||
- Toast
|
||||
- Progress
|
||||
- Skeleton
|
||||
|
||||
**Navigation:**
|
||||
|
||||
- Tabs
|
||||
- Dropdown Menu
|
||||
- Command
|
||||
- Popover
|
||||
- Sheet (Drawer)
|
||||
|
||||
**Layout:**
|
||||
|
||||
- Accordion
|
||||
- Collapsible
|
||||
- Aspect Ratio
|
||||
- Scroll Area
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Full Control:** แก้ไข Components ได้เต็มที่
|
||||
2. ✅ **Smaller Bundle:** เฉพาะที่ใช้เท่านั้น
|
||||
3. ✅ **No Lock-in:** ไม่ติด Dependency
|
||||
4. ✅ **Accessibility:** ใช้ Radix UI (tested)
|
||||
5. ✅ **Beautiful Design:** ดูทันสมัยและสวยงาม
|
||||
6. ✅ **TypeScript:** Type-safe
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Manual Updates:** ต้อง Update components ด้วยตัวเอง
|
||||
2. ❌ **Initial Setup:** ต้อง Copy components ที่ต้องการ
|
||||
3. ❌ **No Official Support:** ไม่มี Package maintainer
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Documentation:** เขียนเอกสารว่า Components ไหนมา version ไหน
|
||||
- **Changelog:** Track changes ที่ทำกับ Components
|
||||
- **Testing:** เขียน Tests สำหรับ Custom components
|
||||
- **Review Updates:** Check Shadcn/UI releases เป็นระยะ
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md) - เลือกใช้ Tailwind CSS
|
||||
- [ADR-011: Next.js App Router](./ADR-011-nextjs-app-router.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Shadcn/UI Documentation](https://ui.shadcn.com/)
|
||||
- [Radix UI Primitives](https://www.radix-ui.com/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2026-06-01
|
||||
497
specs/05-decisions/ADR-013-form-handling-validation.md
Normal file
497
specs/05-decisions/ADR-013-form-handling-validation.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# ADR-013: Form Handling & Validation Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Frontend Team
|
||||
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ระบบ LCBP3-DMS มี Forms จำนวนมาก (Create/Edit Correspondence, RFA, Drawings) ต้องการวิธีจัดการ Forms ที่มี Performance ดี Validation ชัดเจน และ Developer Experience สูง
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Form State Management:** จัดการ Form state อย่างไร
|
||||
2. **Validation:** Validate client-side และ server-side อย่างไร
|
||||
3. **Error Handling:** แสดง Error messages อย่างไร
|
||||
4. **Performance:** Forms ขนาดใหญ่ไม่ช้า
|
||||
5. **Type Safety:** Type-safe forms with TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- ✅ **Type Safety:** TypeScript support เต็มรูปแบบ
|
||||
- ⚡ **Performance:** Re-render minimal
|
||||
- 🎯 **DX:** Developer Experience ดี
|
||||
- 📝 **Validation:** Schema-based validation
|
||||
- 🔄 **Reusability:** Reuse validation schema
|
||||
- 🎨 **Flexibility:** ปรับแต่งได้ง่าย
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Formik
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Popular และ Mature
|
||||
- ✅ Documentation ดี
|
||||
- ✅ Yup validation
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Performance issues (re-renders)
|
||||
- ❌ Bundle size ใหญ่
|
||||
- ❌ TypeScript support ไม่ดีมาก
|
||||
- ❌ Not actively maintained
|
||||
|
||||
### Option 2: Plain React State
|
||||
|
||||
```typescript
|
||||
const [formData, setFormData] = useState({});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple
|
||||
- ✅ No dependencies
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Boilerplate code มาก
|
||||
- ❌ ต้องจัดการ Validation เอง
|
||||
- ❌ Error handling ซับซ้อน
|
||||
- ❌ Performance issues
|
||||
|
||||
### Option 3: React Hook Form + Zod
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Performance:** Uncontrolled components (minimal re-renders)
|
||||
- ✅ **TypeScript First:** Full type safety
|
||||
- ✅ **Small Bundle:** ~8.5kb
|
||||
- ✅ **Schema Validation:** Zod integration
|
||||
- ✅ **DX:** Clean API
|
||||
- ✅ **Actively Maintained**
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Learning curve (uncontrolled approach)
|
||||
- ❌ Complex forms ต้องใช้ Controller
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Option 3 - React Hook Form + Zod**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Performance:** Uncontrolled components = minimal re-renders
|
||||
2. **Type Safety:** Zod schemas → TypeScript types → Runtime validation
|
||||
3. **Bundle Size:** เล็กมาก (8.5kb)
|
||||
4. **Developer Experience:** API สะอาด ใช้งานง่าย
|
||||
5. **Validation Reuse:** Validation schema ใช้ร่วมกับ Backend ได้
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install react-hook-form zod @hookform/resolvers
|
||||
```
|
||||
|
||||
### 2. Define Zod Schema
|
||||
|
||||
```typescript
|
||||
// File: lib/validations/correspondence.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const correspondenceSchema = z.object({
|
||||
subject: z
|
||||
.string()
|
||||
.min(5, 'Subject must be at least 5 characters')
|
||||
.max(255, 'Subject must not exceed 255 characters'),
|
||||
|
||||
description: z
|
||||
.string()
|
||||
.min(10, 'Description must be at least 10 characters')
|
||||
.optional(),
|
||||
|
||||
document_type_id: z.number({
|
||||
required_error: 'Document type is required',
|
||||
}),
|
||||
|
||||
from_organization_id: z.number({
|
||||
required_error: 'From organization is required',
|
||||
}),
|
||||
|
||||
to_organization_id: z.number({
|
||||
required_error: 'To organization is required',
|
||||
}),
|
||||
|
||||
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
|
||||
|
||||
attachments: z.array(z.instanceof(File)).optional(),
|
||||
});
|
||||
|
||||
// Export TypeScript type
|
||||
export type CorrespondenceFormData = z.infer<typeof correspondenceSchema>;
|
||||
```
|
||||
|
||||
### 3. Create Form Component
|
||||
|
||||
```typescript
|
||||
// File: components/correspondences/create-form.tsx
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
correspondenceSchema,
|
||||
type CorrespondenceFormData,
|
||||
} from '@/lib/validations/correspondence';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export function CreateCorrespondenceForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
setValue,
|
||||
} = useForm<CorrespondenceFormData>({
|
||||
resolver: zodResolver(correspondenceSchema),
|
||||
defaultValues: {
|
||||
importance: 'NORMAL',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CorrespondenceFormData) => {
|
||||
try {
|
||||
const response = await fetch('/api/correspondences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create');
|
||||
|
||||
// Success - redirect
|
||||
window.location.href = '/correspondences';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
{...register('subject')}
|
||||
placeholder="Enter subject"
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="text-sm text-red-600 mt-1">{errors.subject.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register('description')}
|
||||
placeholder="Enter description"
|
||||
rows={4}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Type (Select) */}
|
||||
<div>
|
||||
<Label>Document Type *</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setValue('document_type_id', parseInt(value))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Internal Letter</SelectItem>
|
||||
<SelectItem value="2">External Letter</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.document_type_id && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{errors.document_type_id.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Importance (Radio) */}
|
||||
<div>
|
||||
<Label>Importance</Label>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<label className="flex items-center">
|
||||
<input type="radio" value="NORMAL" {...register('importance')} />
|
||||
<span className="ml-2">Normal</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="radio" value="HIGH" {...register('importance')} />
|
||||
<span className="ml-2">High</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input type="radio" value="URGENT" {...register('importance')} />
|
||||
<span className="ml-2">Urgent</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Reusable Form Field Component
|
||||
|
||||
```typescript
|
||||
// File: components/ui/form-field.tsx
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { UseFormRegister, FieldError } from 'react-hook-form';
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
register: UseFormRegister<any>;
|
||||
error?: FieldError;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
register,
|
||||
error,
|
||||
required = false,
|
||||
placeholder,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={name}>
|
||||
{label} {required && <span className="text-red-600">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type={type}
|
||||
{...register(name)}
|
||||
placeholder={placeholder}
|
||||
className={error ? 'border-red-600' : ''}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600 mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. File Upload Handling
|
||||
|
||||
```typescript
|
||||
// File: components/correspondences/file-upload.tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { UseFormSetValue } from 'react-hook-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface FileUploadProps {
|
||||
setValue: UseFormSetValue<any>;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export function FileUpload({ setValue, fieldName }: FileUploadProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
setFiles(selectedFiles);
|
||||
setValue(fieldName, selectedFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload">
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<span>Choose Files</span>
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
{files.map((file, i) => (
|
||||
<div key={i}>{file.name}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Server-Side Validation
|
||||
|
||||
```typescript
|
||||
// File: app/api/correspondences/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { correspondenceSchema } from '@/lib/validations/correspondence';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate with same Zod schema
|
||||
const validated = correspondenceSchema.parse(body);
|
||||
|
||||
// Create correspondence
|
||||
// ...
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', issues: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form Patterns
|
||||
|
||||
### Dynamic Fields
|
||||
|
||||
```typescript
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'items', // RFA items
|
||||
});
|
||||
|
||||
// Add item
|
||||
append({ description: '', quantity: 0 });
|
||||
|
||||
// Remove item
|
||||
remove(index);
|
||||
```
|
||||
|
||||
### Controlled Components
|
||||
|
||||
```typescript
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
<Controller
|
||||
name="discipline_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
{/* Options */}
|
||||
</Select>
|
||||
)}
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Performance:** Minimal re-renders (uncontrolled)
|
||||
2. ✅ **Type Safety:** Full TypeScript support
|
||||
3. ✅ **Validation Reuse:** Same schema for client & server
|
||||
4. ✅ **Small Bundle:** ~8.5kb only
|
||||
5. ✅ **Clean Code:** Less boilerplate
|
||||
6. ✅ **Error Handling:** Built-in error states
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Learning Curve:** Uncontrolled approach ต่างจาก Formik
|
||||
2. ❌ **Complex Forms:** ต้องใช้ Controller บางครั้ง
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Documentation:** เขียน Form patterns และ Examples
|
||||
- **Reusable Components:** สร้าง FormField wrapper
|
||||
- **Code Review:** Review forms ให้ใช้ best practices
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-007: API Design & Error Handling](./ADR-007-api-design-error-handling.md)
|
||||
- [ADR-012: UI Component Library](./ADR-012-ui-component-library.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [React Hook Form Documentation](https://react-hook-form.com/)
|
||||
- [Zod Documentation](https://zod.dev/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2026-06-01
|
||||
400
specs/05-decisions/ADR-014-state-management.md
Normal file
400
specs/05-decisions/ADR-014-state-management.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# ADR-014: State Management Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Frontend Team
|
||||
**Related Documents:** [Frontend Guidelines](../03-implementation/frontend-guidelines.md), [ADR-011: App Router](./ADR-011-nextjs-app-router.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
ระบบ LCBP3-DMS ต้องการจัดการ Global State เช่น User session, Notifications, UI preferences ต้องเลือก State Management solution ที่เหมาะสม
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Global State:** จัดการ State ที่ใช้ร่วมกันทั้งแอปอย่างไร
|
||||
2. **Server State:** จัดการข้อมูลจาก API อย่างไร
|
||||
3. **Performance:** หลีกเลี่ยง Unnecessary re-renders
|
||||
4. **Type Safety:** Type-safe state management
|
||||
5. **Bundle Size:** ไม่ทำให้ Bundle ใหญ่เกินไป
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- ⚡ **Performance:** Minimal re-renders
|
||||
- 📦 **Bundle Size:** เล็กที่สุด
|
||||
- 🎯 **Simplicity:** เรียนรู้และใช้งานง่าย
|
||||
- ✅ **Type Safety:** TypeScript support
|
||||
- 🔄 **Server State:** จัดการ API data ได้ดี
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Redux Toolkit
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Industry standard
|
||||
- ✅ DevTools ดี
|
||||
- ✅ Middleware support
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Boilerplate มาก
|
||||
- ❌ Bundle size ใหญ่ (~40kb)
|
||||
- ❌ Complexity สูง
|
||||
- ❌ Overkill สำหรับ App ส่วนใหญ่
|
||||
|
||||
### Option 2: React Context API
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Built-in (no dependencies)
|
||||
- ✅ Simple
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Performance issues (re-render ทั้ง tree)
|
||||
- ❌ ไม่เหมาะสำหรับ Complex state
|
||||
- ❌ ต้องจัดการ Optimization เอง
|
||||
|
||||
### Option 3: Zustand
|
||||
|
||||
**Props:**
|
||||
|
||||
- ✅ **Lightweight:** ~1.2kb only
|
||||
- ✅ **Simple API:** เรียนรู้ง่าย
|
||||
- ✅ **Performance:** Selective re-renders
|
||||
- ✅ **TypeScript:** Full support
|
||||
- ✅ **No boilerplate**
|
||||
- ✅ **DevTools support**
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Smaller community กว่า Redux
|
||||
|
||||
### Option 4: React Query (TanStack Query) for Server State
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ **Specialized:** จัดการ Server state ได้ดีที่สุด
|
||||
- ✅ **Caching:** Auto cache management
|
||||
- ✅ **Refetching:** Auto refetch on focus
|
||||
- ✅ **TypeScript:** Excellent support
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ เฉพาะ Server state (ต้องใช้คู่กับ Client state solution)
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Zustand (Client State) + Native Fetch with Server Components (Server State)**
|
||||
|
||||
### Rationale
|
||||
|
||||
**For Client State (UI state, Preferences):**
|
||||
|
||||
- Use **Zustand** - lightweight และเรียนรู้ง่าย
|
||||
|
||||
**For Server State (API data):**
|
||||
|
||||
- Use **Server Components** + **SWR** (เฉพาะที่จำเป็น)
|
||||
- Server Components ดึงข้อมูลฝั่ง Server ไม่ต้องจัดการ state
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Install Zustand
|
||||
|
||||
```bash
|
||||
npm install zustand
|
||||
```
|
||||
|
||||
### 2. Create Global Store (User Session)
|
||||
|
||||
```typescript
|
||||
// File: lib/stores/auth-store.ts
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface User {
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// Actions
|
||||
setAuth: (user: User, token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
setAuth: (user, token) =>
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage', // LocalStorage key
|
||||
}
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Use Store in Components
|
||||
|
||||
```typescript
|
||||
// File: components/header.tsx
|
||||
'use client';
|
||||
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
return (
|
||||
<header className="flex justify-between items-center p-4">
|
||||
<div>Welcome, {user?.first_name}</div>
|
||||
<Button onClick={logout}>Logout</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Notifications Store
|
||||
|
||||
```typescript
|
||||
// File: lib/stores/notification-store.ts
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
|
||||
addNotification: (notification: Omit<Notification, 'id'>) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
notifications: [],
|
||||
|
||||
addNotification: (notification) =>
|
||||
set((state) => ({
|
||||
notifications: [
|
||||
...state.notifications,
|
||||
{ ...notification, id: Math.random().toString() },
|
||||
],
|
||||
})),
|
||||
|
||||
removeNotification: (id) =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
})),
|
||||
|
||||
clearAll: () => set({ notifications: [] }),
|
||||
}));
|
||||
```
|
||||
|
||||
### 5. Server State with Server Components
|
||||
|
||||
```typescript
|
||||
// File: app/(dashboard)/correspondences/page.tsx
|
||||
// Server Component - No state management needed!
|
||||
|
||||
import { getCorrespondences } from '@/lib/api/correspondences';
|
||||
|
||||
export default async function CorrespondencesPage() {
|
||||
// Fetch directly on server
|
||||
const correspondences = await getCorrespondences();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Correspondences</h1>
|
||||
{correspondences.map((item) => (
|
||||
<div key={item.id}>{item.subject}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Client-Side Fetching (with SWR for real-time data)
|
||||
|
||||
```bash
|
||||
npm install swr
|
||||
```
|
||||
|
||||
```typescript
|
||||
// File: components/correspondences/realtime-list.tsx
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function RealtimeCorrespondenceList() {
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
'/api/correspondences',
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 30000, // Auto refresh every 30s
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error loading data</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.map((item) => (
|
||||
<div key={item.id}>{item.subject}</div>
|
||||
))}
|
||||
<button onClick={() => mutate()}>Refresh</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 7. UI Preferences Store
|
||||
|
||||
```typescript
|
||||
// File: lib/stores/ui-store.ts
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface UIState {
|
||||
sidebarCollapsed: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
|
||||
toggleSidebar: () => void;
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
}),
|
||||
{
|
||||
name: 'ui-preferences',
|
||||
}
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management Patterns
|
||||
|
||||
### When to Use Zustand (Client State)
|
||||
|
||||
✅ Use Zustand for:
|
||||
|
||||
- User authentication state
|
||||
- UI preferences (theme, sidebar state)
|
||||
- Notifications/Toasts
|
||||
- Shopping cart (if applicable)
|
||||
- Form wizard state
|
||||
- Modal state (global)
|
||||
|
||||
### When to Use Server Components (Server State)
|
||||
|
||||
✅ Use Server Components for:
|
||||
|
||||
- Initial data loading
|
||||
- Static/semi-static data
|
||||
- SEO-important content
|
||||
- Data that doesn't need real-time updates
|
||||
|
||||
### When to Use SWR (Client-Side Server State)
|
||||
|
||||
✅ Use SWR for:
|
||||
|
||||
- Real-time data (notifications count)
|
||||
- Polling/Auto-refresh data
|
||||
- User-specific data that changes often
|
||||
- Optimistic UI updates
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Lightweight:** Zustand ~1.2kb
|
||||
2. ✅ **Simple:** Easy to learn and use
|
||||
3. ✅ **Performance:** Selective re-renders
|
||||
4. ✅ **No Boilerplate:** Clean API
|
||||
5. ✅ **Type Safe:** Full TypeScript support
|
||||
6. ✅ **Persistent:** Easy LocalStorage persist
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Smaller Ecosystem:** กว่า Redux
|
||||
2. ❌ **Less Tooling:** DevTools ไม่ครบเท่า Redux
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Documentation:** Document common patterns
|
||||
- **Code Examples:** Provide store templates
|
||||
- **Testing:** Unit test stores thoroughly
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-011: Next.js App Router](./ADR-011-nextjs-app-router.md) - Server Components
|
||||
- [ADR-007: API Design](./ADR-007-api-design-error-handling.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Zustand Documentation](https://github.com/pmndrs/zustand)
|
||||
- [SWR Documentation](https://swr.vercel.app/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2026-06-01
|
||||
457
specs/05-decisions/ADR-015-deployment-infrastructure.md
Normal file
457
specs/05-decisions/ADR-015-deployment-infrastructure.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# ADR-015: Deployment & Infrastructure Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** DevOps Team, System Architect
|
||||
**Related Documents:** [ADR-005: Technology Stack](./ADR-005-technology-stack.md), [Operations Guide](../04-operations/)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS ต้อง Deploy บน QNAP Container Station โดยใช้ Docker แต่ต้องเลือกกลย modularุทธ์การ Deploy, การจัดการ Environment, และการ Scale ที่เหมาะสม
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Container Orchestration:** ใช้ Docker Compose หรือ Kubernetes
|
||||
2. **Environment Management:** จัดการ Environment Variables อย่างไร
|
||||
3. **Deployment Strategy:** Blue-Green, Rolling Update, หรือ Recreate
|
||||
4. **Scaling:** แผน Scale horizontal/vertical
|
||||
5. **Persistence:** จัดการ Data persistence อย่างไร
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 🎯 **Simplicity:** ง่ายต่อการ Deploy และ Maintain
|
||||
- 🔒 **Security:** Secrets management ปลอดภัย
|
||||
- ⚡ **Zero Downtime:** Deploy ได้โดยไม่มี Downtime
|
||||
- 📦 **Resource Efficiency:** ใช้ทรัพยากร QNAP อย่างคุ้มค่า
|
||||
- 🔄 **Rollback Capability:** Rollback ได้เมื่อมีปัญหา
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: Docker Compose (Single Server)
|
||||
|
||||
**Deployment:**
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: lcbp3-backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
- mariadb
|
||||
- redis
|
||||
networks:
|
||||
- lcbp3-network
|
||||
|
||||
frontend:
|
||||
image: lcbp3-frontend:latest
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- lcbp3-network
|
||||
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
volumes:
|
||||
- mariadb-data:/var/lib/mysql
|
||||
networks:
|
||||
- lcbp3-network
|
||||
|
||||
redis:
|
||||
image: redis:7.2-alpine
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- lcbp3-network
|
||||
|
||||
elasticsearch:
|
||||
image: elasticsearch:8.11.0
|
||||
volumes:
|
||||
- elastic-data:/usr/share/elasticsearch/data
|
||||
networks:
|
||||
- lcbp3-network
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./ssl:/etc/nginx/ssl
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
networks:
|
||||
- lcbp3-network
|
||||
|
||||
volumes:
|
||||
mariadb-data:
|
||||
redis-data:
|
||||
elastic-data:
|
||||
|
||||
networks:
|
||||
lcbp3-network:
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Simple และเข้าใจง่าย
|
||||
- ✅ พอดีกับ QNAP Container Station
|
||||
- ✅ Resource requirement ต่ำ
|
||||
- ✅ Debugging ง่าย
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Single point of failure
|
||||
- ❌ ไม่มี Auto-scaling
|
||||
- ❌ Service discovery manual
|
||||
|
||||
### Option 2: Kubernetes (k3s)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Auto-scaling
|
||||
- ✅ Self-healing
|
||||
- ✅ Service discovery
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ ซับซ้อนเกินความจำเป็น
|
||||
- ❌ Resource overhead สูง
|
||||
- ❌ Learning curve สูง
|
||||
- ❌ Overkill สำหรับ Single server
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** **Docker Compose with Blue-Green Deployment Strategy**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Appropriate Complexity:** เหมาะกับ Scale และทีมของโปรเจกต์
|
||||
2. **QNAP Compatibility:** รองรับโดย QNAP Container Station
|
||||
3. **Resource Efficiency:** ใช้ทรัพยากรน้อยกว่า K8s
|
||||
4. **Team Familiarity:** ทีม DevOps คุ้นเคยกับ Docker Compose
|
||||
5. **Easy Rollback:** Rollback ได้ง่ายด้วย Tagged images
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Directory Structure
|
||||
|
||||
```
|
||||
/volume1/lcbp3/
|
||||
├── blue/
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env.production
|
||||
│ └── nginx.conf
|
||||
│
|
||||
├── green/
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env.production
|
||||
│ └── nginx.conf
|
||||
│
|
||||
├── nginx-proxy/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── nginx.conf (routes to blue or green)
|
||||
│
|
||||
├── shared/
|
||||
│ ├── uploads/
|
||||
│ ├── logs/
|
||||
│ └── backups/
|
||||
│
|
||||
└── volumes/
|
||||
├── mariadb-data/
|
||||
├── redis-data/
|
||||
└── elastic-data/
|
||||
```
|
||||
|
||||
### 2. Blue-Green Deployment Process
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# File: scripts/deploy.sh
|
||||
|
||||
CURRENT=$(cat /volume1/lcbp3/current)
|
||||
TARGET=$([[ "$CURRENT" == "blue" ]] && echo "green" || echo "blue")
|
||||
|
||||
echo "Current environment: $CURRENT"
|
||||
echo "Deploying to: $TARGET"
|
||||
|
||||
cd /volume1/lcbp3/$TARGET
|
||||
|
||||
# 1. Pull latest images
|
||||
docker-compose pull
|
||||
|
||||
# 2. Start new environment
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Run database migrations
|
||||
docker exec lcbp3-${TARGET}-backend npm run migration:run
|
||||
|
||||
# 4. Health check
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:${TARGET}_PORT/health; then
|
||||
echo "Health check passed"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 5. Switch nginx to new environment
|
||||
sed -i "s/$CURRENT/$TARGET/g" /volume1/lcbp3/nginx-proxy/nginx.conf
|
||||
docker exec lcbp3-nginx nginx -s reload
|
||||
|
||||
# 6. Update current pointer
|
||||
echo "$TARGET" > /volume1/lcbp3/current
|
||||
|
||||
# 7. Stop old environment (keep data)
|
||||
cd /volume1/lcbp3/$CURRENT
|
||||
docker-compose down
|
||||
|
||||
echo "Deployment complete: $TARGET is now active"
|
||||
```
|
||||
|
||||
### 3. Environment Variables Management
|
||||
|
||||
```bash
|
||||
# File: .env.production (NOT in Git)
|
||||
NODE_ENV=production
|
||||
|
||||
# Database
|
||||
DB_HOST=mariadb
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=lcbp3_user
|
||||
DB_PASSWORD=<secret>
|
||||
DB_DATABASE=lcbp3_dms
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<secret>
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=<secret>
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# File Storage
|
||||
UPLOAD_PATH=/app/uploads
|
||||
ALLOWED_FILE_TYPES=.pdf,.doc,.docx,.xls,.xlsx,.dwg
|
||||
|
||||
# Email
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=<secret>
|
||||
SMTP_PASSWORD=<secret>
|
||||
```
|
||||
|
||||
**Secrets Management:**
|
||||
|
||||
- Production `.env` files stored on QNAP only (NOT in Git)
|
||||
- Use `docker-compose.override.yml` for local development
|
||||
- Validate required env vars at application startup
|
||||
|
||||
### 4. Volume Management
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
# Persistent data (survives container recreation)
|
||||
mariadb-data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: /volume1/lcbp3/volumes/mariadb-data
|
||||
o: bind
|
||||
|
||||
# Shared uploads across blue/green
|
||||
uploads:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: /volume1/lcbp3/shared/uploads
|
||||
o: bind
|
||||
|
||||
# Logs
|
||||
logs:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: /volume1/lcbp3/shared/logs
|
||||
o: bind
|
||||
```
|
||||
|
||||
### 5. NGINX Reverse Proxy
|
||||
|
||||
```nginx
|
||||
# File: nginx-proxy/nginx.conf
|
||||
upstream backend {
|
||||
server lcbp3-blue-backend:3000; # Switch to green during deployment
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server lcbp3-blue-frontend:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name lcbp3-dms.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name lcbp3-dms.example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Timeouts for file uploads
|
||||
client_max_body_size 50M;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://backend/health;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scaling Strategy
|
||||
|
||||
### Vertical Scaling (Phase 1)
|
||||
|
||||
**Current Recommendation:**
|
||||
|
||||
- Backend: 2 CPU cores, 4GB RAM
|
||||
- Frontend: 1 CPU core, 2GB RAM
|
||||
- MariaDB: 2 CPU cores, 8GB RAM
|
||||
- Redis: 1 CPU core, 2GB RAM
|
||||
- Elasticsearch: 2 CPU cores, 4GB RAM
|
||||
|
||||
**Upgrade Path:**
|
||||
|
||||
- Increase CPU/RAM ตาม Load
|
||||
- Monitor with Prometheus/Grafana
|
||||
|
||||
### Horizontal Scaling (Phase 2 - Future)
|
||||
|
||||
**If needed:**
|
||||
|
||||
- Load Balancer หน้า Backend (multiple replicas)
|
||||
- Database Read Replicas
|
||||
- Redis Cluster
|
||||
- Elasticsearch Cluster
|
||||
|
||||
**Prerequisite:**
|
||||
|
||||
- Stateless application (sessions in Redis)
|
||||
- Shared file storage (NFS/S3)
|
||||
|
||||
---
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
```markdown
|
||||
### Pre-Deployment
|
||||
|
||||
- [ ] Backup database
|
||||
- [ ] Tag Docker images
|
||||
- [ ] Update .env file
|
||||
- [ ] Review migration scripts
|
||||
- [ ] Notify stakeholders
|
||||
|
||||
### Deployment
|
||||
|
||||
- [ ] Pull latest images
|
||||
- [ ] Start target environment (blue/green)
|
||||
- [ ] Run migrations
|
||||
- [ ] Health check passes
|
||||
- [ ] Switch NGINX proxy
|
||||
- [ ] Verify application working
|
||||
|
||||
### Post-Deployment
|
||||
|
||||
- [ ] Monitor logs for errors
|
||||
- [ ] Check performance metrics
|
||||
- [ ] Verify all features working
|
||||
- [ ] Stop old environment
|
||||
- [ ] Update deployment log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Simple Deployment:** Docker Compose เข้าใจง่าย
|
||||
2. ✅ **Zero Downtime:** Blue-Green Deployment ไม่มี Downtime
|
||||
3. ✅ **Easy Rollback:** Rollback = Switch NGINX back
|
||||
4. ✅ **Cost Effective:** ไม่ต้อง Kubernetes overhead
|
||||
5. ✅ **QNAP Compatible:** ใช้ได้กับ Container Station
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Manual Scaling:** ต้อง Scale manual
|
||||
2. ❌ **Single Server:** ไม่มี High Availability
|
||||
3. ❌ **Limited Auto-healing:** ต้อง Monitor และ Restart manual
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Monitoring:** Setup Prometheus + Alertmanager
|
||||
- **Automated Backups:** Cron jobs สำหรับ Database backups
|
||||
- **Documentation:** เขียน Runbook สำหรับ Common issues
|
||||
- **Health Checks:** Implement comprehensive health endpoints
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005: Technology Stack](./ADR-005-technology-stack.md)
|
||||
- [ADR-009: Database Migration Strategy](./ADR-009-database-migration-strategy.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Blue-Green Deployment](https://martinfowler.com/bliki/BlueGreenDeployment.html)
|
||||
- [QNAP Container Station](https://www.qnap.com/en/software/container-station)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2026-06-01
|
||||
451
specs/05-decisions/ADR-016-security-authentication.md
Normal file
451
specs/05-decisions/ADR-016-security-authentication.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# ADR-016: Security & Authentication Strategy
|
||||
|
||||
**Status:** ✅ Accepted
|
||||
**Date:** 2025-12-01
|
||||
**Decision Makers:** Security Team, System Architect
|
||||
**Related Documents:** [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md), [ADR-007: API Design](./ADR-007-api-design-error-handling.md)
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
LCBP3-DMS จัดการเอกสารสำคัญของโปรเจกต์ ต้องการ Security strategy ที่ครอบคลุม Authentication, Authorization, Data protection, และ Security best practices
|
||||
|
||||
### ปัญหาที่ต้องแก้:
|
||||
|
||||
1. **Authentication:** ใช้วิธีไหนในการยืนยันตัวตน
|
||||
2. **Session Management:** จัดการ Session อย่างไร
|
||||
3. **Password Security:** เก็บ Password อย่างไรให้ปลอดภัย
|
||||
4. **Data Encryption:** Encrypt ข้อมูลอย่างไร
|
||||
5. **Security Headers:** HTTP Headers ที่ต้องมี
|
||||
6. **Input Validation:** ป้องกัน Injection attacks
|
||||
7. **Rate Limiting:** ป้องกัน Brute force attacks
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 🔒 **Security First:** ความปลอดภัยเป็นสำคัญที่สุด
|
||||
- ✅ **Industry Standards:** ใช้ Standard practices (OWASP)
|
||||
- 🎯 **User Experience:** ไม่ซับซ้อนเกินไป
|
||||
- 📝 **Audit Trail:** บันทึก Security events ทั้งหมด
|
||||
- 🔄 **Token Refresh:** Session management ที่สะดวก
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
### 1. Authentication Strategy
|
||||
|
||||
**Chosen:** **JWT (JSON Web Tokens) with HTTP-only Cookies**
|
||||
|
||||
```typescript
|
||||
// File: src/auth/auth.service.ts
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly usersService: UsersService
|
||||
) {}
|
||||
|
||||
async login(credentials: LoginDto): Promise<{ tokens }> {
|
||||
const user = await this.validateUser(credentials);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: user.user_id,
|
||||
username: user.username,
|
||||
roles: user.roles,
|
||||
};
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = this.jwtService.sign(payload, {
|
||||
expiresIn: '15m', // Short-lived
|
||||
});
|
||||
|
||||
const refreshToken = this.jwtService.sign(payload, {
|
||||
secret: process.env.JWT_REFRESH_SECRET,
|
||||
expiresIn: '7d', // Long-lived
|
||||
});
|
||||
|
||||
// Store refresh token (hashed) in database
|
||||
await this.storeRefreshToken(user.user_id, refreshToken);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private async validateUser(credentials: LoginDto) {
|
||||
const user = await this.usersService.findByUsername(credentials.username);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Use bcrypt for password comparison
|
||||
const isValid = await bcrypt.compare(
|
||||
credentials.password,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
return isValid ? user : null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Password Security
|
||||
|
||||
**Strategy:** **bcrypt with salt rounds = 12**
|
||||
|
||||
```typescript
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
// Hash password
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
async function verifyPassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
```
|
||||
|
||||
**Password Policy:**
|
||||
|
||||
- Minimum 8 characters
|
||||
- Mix of uppercase, lowercase, numbers
|
||||
- No common passwords (check against dictionary)
|
||||
- Password history (last 5 passwords)
|
||||
- Force change every 90 days (optional)
|
||||
|
||||
### 3. JWT Guard (Authorization)
|
||||
|
||||
```typescript
|
||||
// File: src/common/guards/jwt-auth.guard.ts
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err, user, info) {
|
||||
if (err || !user) {
|
||||
throw new UnauthorizedException(info?.message || 'Unauthorized');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Data Encryption
|
||||
|
||||
**At Rest:**
|
||||
|
||||
- Database: Use MariaDB encryption at column level (for sensitive fields)
|
||||
- Files: Encrypt before storing (AES-256)
|
||||
|
||||
```typescript
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const algorithm = 'aes-256-gcm';
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||
|
||||
function encrypt(text: string): { encrypted: string; iv: string; tag: string } {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
iv: iv.toString('hex'),
|
||||
tag: tag.toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
function decrypt(encrypted: string, iv: string, tag: string): string {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
algorithm,
|
||||
key,
|
||||
Buffer.from(iv, 'hex')
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(tag, 'hex'));
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
```
|
||||
|
||||
**In Transit:**
|
||||
|
||||
- HTTPS only (TLS 1.3)
|
||||
- HSTS enabled
|
||||
- Certificate from trusted CA
|
||||
|
||||
### 5. Security Headers
|
||||
|
||||
```typescript
|
||||
// File: src/main.ts
|
||||
import helmet from 'helmet';
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
},
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
frameguard: { action: 'deny' },
|
||||
xssFilter: true,
|
||||
noSniff: true,
|
||||
})
|
||||
);
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Input Validation
|
||||
|
||||
**Strategy:** **Class-validator + Zod + Custom Sanitization**
|
||||
|
||||
```typescript
|
||||
// DTO Validation
|
||||
import { IsString, IsEmail, MinLength, Matches } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||
message: 'Password must contain uppercase, lowercase, and number',
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
|
||||
// SQL Injection Prevention (TypeORM handles this)
|
||||
// Use parameterized queries ALWAYS
|
||||
|
||||
// XSS Prevention
|
||||
import * as sanitizeHtml from 'sanitize-html';
|
||||
|
||||
function sanitizeInput(input: string): string {
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: [], // No HTML tags
|
||||
allowedAttributes: {},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Rate Limiting
|
||||
|
||||
```typescript
|
||||
// File: src/common/guards/rate-limit.guard.ts
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
@Injectable()
|
||||
export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
protected getTracker(req: Request): string {
|
||||
// Track by IP + User ID (if authenticated)
|
||||
return req.ip + (req.user?.user_id || '');
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to login endpoint
|
||||
@Controller('auth')
|
||||
@UseGuards(CustomThrottlerGuard)
|
||||
export class AuthController {
|
||||
@Post('login')
|
||||
@Throttle(5, 60) // 5 attempts per minute
|
||||
async login(@Body() credentials: LoginDto) {
|
||||
return this.authService.login(credentials);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Session Management
|
||||
|
||||
**Strategy:** **Stateless JWT + Refresh Token in Database**
|
||||
|
||||
```typescript
|
||||
// Refresh token table
|
||||
@Entity('refresh_tokens')
|
||||
export class RefreshToken {
|
||||
@PrimaryGeneratedColumn()
|
||||
token_id: number;
|
||||
|
||||
@Column()
|
||||
user_id: number;
|
||||
|
||||
@Column()
|
||||
token_hash: string; // SHA-256 hash of token
|
||||
|
||||
@Column()
|
||||
expires_at: Date;
|
||||
|
||||
@Column({ default: false })
|
||||
is_revoked: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// Token refresh endpoint
|
||||
@Post('refresh')
|
||||
async refresh(@Body('refreshToken') token: string) {
|
||||
const payload = this.jwtService.verify(token, {
|
||||
secret: process.env.JWT_REFRESH_SECRET,
|
||||
});
|
||||
|
||||
// Check if token is revoked
|
||||
const storedToken = await this.findRefreshToken(token);
|
||||
if (!storedToken || storedToken.is_revoked) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const newAccessToken = this.jwtService.sign({
|
||||
sub: payload.sub,
|
||||
username: payload.username,
|
||||
roles: payload.roles,
|
||||
});
|
||||
|
||||
return { accessToken: newAccessToken };
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Audit Logging (Security Events)
|
||||
|
||||
```typescript
|
||||
// Log all security-related events
|
||||
await this.auditLogService.create({
|
||||
user_id: user.user_id,
|
||||
action: 'LOGIN_SUCCESS',
|
||||
entity_type: 'auth',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
// Track failed login attempts
|
||||
await this.auditLogService.create({
|
||||
action: 'LOGIN_FAILED',
|
||||
entity_type: 'auth',
|
||||
ip_address: req.ip,
|
||||
details: { username: credentials.username },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
### Application Security
|
||||
|
||||
- [x] JWT authentication with short-lived tokens
|
||||
- [x] Password hashing with bcrypt (12 rounds)
|
||||
- [x] HTTPS only (TLS 1.3)
|
||||
- [x] Security headers (Helmet.js)
|
||||
- [x] CORS properly configured
|
||||
- [x] Input validation (class-validator)
|
||||
- [x] SQL injection prevention (TypeORM)
|
||||
- [x] XSS prevention (sanitize-html)
|
||||
- [x] CSRF protection (SameSite cookies)
|
||||
- [x] Rate limiting (Throttler)
|
||||
|
||||
### Data Security
|
||||
|
||||
- [x] Sensitive data encrypted at rest (AES-256)
|
||||
- [x] Passwords hashed (bcrypt)
|
||||
- [x] Secrets in environment variables (not in code)
|
||||
- [x] Database credentials rotated regularly
|
||||
- [x] Backup encryption enabled
|
||||
|
||||
### Access Control
|
||||
|
||||
- [x] 4-level RBAC implemented
|
||||
- [x] Principle of least privilege
|
||||
- [x] Role-based permissions
|
||||
- [x] Session timeout (15 minutes)
|
||||
- [x] Audit logging for all actions
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [x] Firewall configured
|
||||
- [x] Intrusion detection (optional)
|
||||
- [x] Regular security updates
|
||||
- [x] Vulnerability scanning
|
||||
- [x] Penetration testing (before go-live)
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
1. ✅ **Secure by Design:** ใช้ Industry best practices
|
||||
2. ✅ **OWASP Compliant:** ครอบคลุม OWASP Top 10
|
||||
3. ✅ **Audit Trail:** บันทึก Security events ทั้งหมด
|
||||
4. ✅ **Token-based:** Stateless และ Scalable
|
||||
5. ✅ **Defense in Depth:** หลายชั้นการป้องกัน
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. ❌ **Complexity:** Security measures เพิ่ม Complexity
|
||||
2. ❌ **Performance:** Encryption/Hashing ใช้ CPU
|
||||
3. ❌ **User Friction:** Password policy อาจรำคาญผู้ใช้
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
- **Documentation:** เขียน Security guidelines ให้ทีม
|
||||
- **Training:** อบรม Security awareness
|
||||
- **Automation:** Automated security scans
|
||||
- **Monitoring:** Real-time security monitoring
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-004: RBAC Implementation](./ADR-004-rbac-implementation.md)
|
||||
- [ADR-007: API Design & Error Handling](./ADR-007-api-design-error-handling.md)
|
||||
- [ADR-015: Deployment & Infrastructure](./ADR-015-deployment-infrastructure.md)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [JWT Best Practices](https://curity.io/resources/learn/jwt-best-practices/)
|
||||
- [NestJS Security](https://docs.nestjs.com/security/authentication)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-01
|
||||
**Next Review:** 2026-03-01 (Quarterly review)
|
||||
356
specs/05-decisions/README.md
Normal file
356
specs/05-decisions/README.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Architecture Decision Records (ADRs)
|
||||
|
||||
**Last Updated:** 2025-11-30
|
||||
**Project:** LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)
|
||||
|
||||
---
|
||||
|
||||
## 📋 What are ADRs?
|
||||
|
||||
Architecture Decision Records (ADRs) เป็นเอกสารที่บันทึก **ประวัติการตัดสินใจทางสถาปัตยกรรมที่สำคัญ** ของโปรเจกต์ โดยร ะบุ:
|
||||
|
||||
- **Context**: เหตุผลที่ต้องตัดสินใจ
|
||||
- **Options Considered**: ทางเลือกที่พิจารณา
|
||||
- **Decision**: สิ่งที่เลือก และเหตุผล
|
||||
- **Consequences**: ผลที่ตามมา (ดีและไม่ดี)
|
||||
|
||||
**วัตถุประสงค์:**
|
||||
|
||||
1. ทำให้ทีมเข้าใจ "ทำไม" นอกเหนือจาก "ทำอย่างไร"
|
||||
2. ป้องกันการสงสัยว่า "ทำไมถึงออกแบบแบบนี้" ในอนาคต
|
||||
3. ช่วยในการ Onboard สมาชิกใหม่
|
||||
4. บันทึกประวัติศาสตร์การพัฒนาโปรเจกต์
|
||||
|
||||
---
|
||||
|
||||
## 📚 ADR Index
|
||||
|
||||
### Core Architecture Decisions
|
||||
|
||||
| ADR | Title | Status | Date | Summary |
|
||||
| --------------------------------------------------- | ------------------------------- | ----------- | ---------- | ---------------------------------------------------------------------------- |
|
||||
| [ADR-001](./ADR-001-unified-workflow-engine.md) | Unified Workflow Engine | ✅ Accepted | 2025-11-30 | ใช้ DSL-based Workflow Engine สำหรับ Correspondences, RFAs, และ Circulations |
|
||||
| [ADR-002](./ADR-002-document-numbering-strategy.md) | Document Numbering Strategy | ✅ Accepted | 2025-11-30 | Double-lock mechanism (Redis + DB Optimistic Lock) สำหรับเลขที่เอกสาร |
|
||||
| [ADR-003](./ADR-003-file-storage-approach.md) | Two-Phase File Storage Approach | ✅ Accepted | 2025-11-30 | Upload → Temp → Commit to Permanent เพื่อป้องกัน Orphan Files |
|
||||
|
||||
### Security & Access Control
|
||||
|
||||
| ADR | Title | Status | Date | Summary |
|
||||
| ------------------------------------------- | ----------------------------- | ----------- | ---------- | ------------------------------------------------------------- |
|
||||
| [ADR-004](./ADR-004-rbac-implementation.md) | RBAC Implementation (4-Level) | ✅ Accepted | 2025-11-30 | Hierarchical RBAC: Global → Organization → Project → Contract |
|
||||
|
||||
### Technology & Infrastructure
|
||||
|
||||
| ADR | Title | Status | Date | Summary |
|
||||
| --------------------------------------------------- | ------------------------------------ | ----------- | ---------- | -------------------------------------------------------------- |
|
||||
| [ADR-005](./ADR-005-technology-stack.md) | Technology Stack Selection | ✅ Accepted | 2025-11-30 | Full Stack TypeScript: NestJS + Next.js + MariaDB + Redis |
|
||||
| [ADR-006](./ADR-006-redis-caching-strategy.md) | Redis Usage & Caching Strategy | ✅ Accepted | 2025-11-30 | Redis สำหรับ Distributed Lock, Cache, Queue, และ Rate Limiting |
|
||||
| [ADR-009](./ADR-009-database-migration-strategy.md) | Database Migration & Deployment | ✅ Accepted | 2025-12-01 | TypeORM Migrations พร้อม Blue-Green Deployment |
|
||||
| [ADR-015](./ADR-015-deployment-infrastructure.md) | Deployment & Infrastructure Strategy | ✅ Accepted | 2025-12-01 | Docker Compose with Blue-Green Deployment on QNAP |
|
||||
| [ADR-016](./ADR-016-security-authentication.md) | Security & Authentication Strategy | ✅ Accepted | 2025-12-01 | JWT + bcrypt + OWASP Security Best Practices |
|
||||
|
||||
### API & Integration
|
||||
|
||||
| ADR | Title | Status | Date | Summary |
|
||||
| --------------------------------------------------- | ----------------------------- | ----------- | ---------- | ----------------------------------------------------------------------------- |
|
||||
| [ADR-007](./ADR-007-api-design-error-handling.md) | API Design & Error Handling | ✅ Accepted | 2025-12-01 | Standard REST API with Custom Error Format + NestJS Exception Filters |
|
||||
| [ADR-008](./ADR-008-email-notification-strategy.md) | Email & Notification Strategy | ✅ Accepted | 2025-12-01 | BullMQ + Redis Queue สำหรับ Multi-channel Notifications (Email, LINE, In-app) |
|
||||
|
||||
### Observability
|
||||
|
||||
| ADR | Title | Status | Date | Summary |
|
||||
| --------------------------------------------------- | ----------------------------- | ----------- | ---------- | ------------------------------------------------------------- |
|
||||
| [ADR-010](./ADR-010-logging-monitoring-strategy.md) | Logging & Monitoring Strategy | ✅ Accepted | 2025-12-01 | Winston Structured Logging พร้อม Future ELK Stack Integration |
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
| ADR | Title | Status | Date | Summary |
|
||||
| ------------------------------------------------ | -------------------------------- | ----------- | ---------- | ----------------------------------------------------- |
|
||||
| [ADR-011](./ADR-011-nextjs-app-router.md) | Next.js App Router & Routing | ✅ Accepted | 2025-12-01 | App Router with Server Components and Nested Layouts |
|
||||
| [ADR-012](./ADR-012-ui-component-library.md) | UI Component Library (Shadcn/UI) | ✅ Accepted | 2025-12-01 | Shadcn/UI + Tailwind CSS for Full Component Ownership |
|
||||
| [ADR-013](./ADR-013-form-handling-validation.md) | Form Handling & Validation | ✅ Accepted | 2025-12-01 | React Hook Form + Zod for Type-Safe Forms |
|
||||
| [ADR-014](./ADR-014-state-management.md) | State Management Strategy | ✅ Accepted | 2025-12-01 | Zustand for Client State + Server Components |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 ADR Categories
|
||||
|
||||
### 1. Business Logic & Workflows
|
||||
|
||||
- **ADR-001:** Unified Workflow Engine - ใช้ JSON DSL แทน Hard-coded routing tables
|
||||
|
||||
### 2. Data Integrity & Concurrency
|
||||
|
||||
- **ADR-002:** Document Numbering - Double-lock เพื่อป้องกัน Race Condition
|
||||
- **ADR-003:** File Storage - Two-phase เพื่อ Transaction safety
|
||||
- **ADR-009:** Database Migration - TypeORM Migrations พร้อม Blue-Green Deployment
|
||||
|
||||
### 3. Security & Access Control
|
||||
|
||||
- **ADR-004:** RBAC - 4-level scope สำหรับ Fine-grained permissions
|
||||
|
||||
### 4. Infrastructure & Performance
|
||||
|
||||
- **ADR-005:** Technology Stack - TypeScript ecosystem
|
||||
- **ADR-006:** Redis - Caching และ Distributed coordination
|
||||
- **ADR-015:** Deployment - Docker Compose with Blue-Green Deployment
|
||||
- **ADR-016:** Security - JWT Authentication + OWASP Best Practices
|
||||
|
||||
### 5. API & Integration
|
||||
|
||||
- **ADR-007:** API Design - REST API with Custom Error Format
|
||||
- **ADR-008:** Notification - BullMQ Queue สำหรับ Multi-channel notifications
|
||||
|
||||
### 6. Observability & Monitoring
|
||||
|
||||
- **ADR-010:** Logging - Winston Structured Logging พร้อม Future ELK Stack
|
||||
|
||||
### 7. Frontend Architecture
|
||||
|
||||
- **ADR-011:** Next.js App Router - Server Components และ Nested Layouts
|
||||
- **ADR-012:** UI Components - Shadcn/UI + Tailwind CSS
|
||||
- **ADR-013:** Form Handling - React Hook Form + Zod Validation
|
||||
- **ADR-014:** State Management - Zustand + Server Components
|
||||
|
||||
---
|
||||
|
||||
## 📖 How to Read ADRs
|
||||
|
||||
### ADR Structure
|
||||
|
||||
แต่ละ ADR มีโครงสร้างดังนี้:
|
||||
|
||||
1. **Status**: Accepted, Proposed, Deprecated, Superseded
|
||||
2. **Context**: ปัญหาหรือสถานการณ์ที่ต้องตัดสินใจ
|
||||
3. **Decision Drivers**: ปัจจัยที่มีผลต่อการตัดสินใจ
|
||||
4. **Considered Options**: ทางเลือกที่พิจารณา (พร้อม Pros/Cons)
|
||||
5. **Decision Outcome**: สิ่งที่เลือก และเหตุผล
|
||||
6. **Consequences**: ผลที่ตามมา (Positive/Negative/Mitigation)
|
||||
7. **Implementation Details**: รายละเอียดการ Implement (Code examples)
|
||||
8. **Related ADRs**: ADR อื่นที่เกี่ยวข้อง
|
||||
|
||||
### Reading Tips
|
||||
|
||||
- เริ่มจาก **Context** เพื่อเข้าใจปัญหา
|
||||
- ดู **Considered Options** เพื่อเข้าใจ Trade-offs
|
||||
- อ่าน **Consequences** เพื่อรู้ว่าต้อง Maintain อย่างไร
|
||||
- ดู **Related ADRs** เพื่อเข้าใจภาพรวม
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Creating New ADRs
|
||||
|
||||
### When to Create an ADR?
|
||||
|
||||
สร้าง ADR เมื่อ:
|
||||
|
||||
- ✅ เลือก Technology/Framework หลัก
|
||||
- ✅ ออกแบบ Architecture Pattern สำคัญ
|
||||
- ✅ แก้ปัญหาซับซ้อนที่มีหลาย Alternatives
|
||||
- ✅ Trade-offs ที่มีผลกระทบระยะยาว
|
||||
- ✅ ตัดสินใจที่ยากจะ Revert (Irreversible decisions)
|
||||
|
||||
**ไม่ต้องสร้าง ADR สำหรับ:**
|
||||
|
||||
- ❌ การเลือก Library เล็กๆ ที่เปลี่ยนได้ง่าย
|
||||
- ❌ Implementation details ที่ไม่กระทบ Architecture
|
||||
- ❌ Coding style หรือ Naming conventions
|
||||
|
||||
### ADR Template
|
||||
|
||||
```markdown
|
||||
# ADR-XXX: [Title]
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** YYYY-MM-DD
|
||||
**Decision Makers:** [Names]
|
||||
**Related Documents:** [Links]
|
||||
|
||||
---
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[Describe the problem...]
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- [Driver 1]
|
||||
- [Driver 2]
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: [Name]
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ [Pro 1]
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ [Con 1]
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option:** [Option X]
|
||||
|
||||
### Rationale
|
||||
|
||||
[Why this option...]
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. ✅ [Impact 1]
|
||||
|
||||
### Negative
|
||||
|
||||
1. ❌ [Risk 1]
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-XXX: Title](./ADR-XXX.md)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 ADR Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Proposed: Create new ADR
|
||||
Proposed --> Accepted: Team agrees
|
||||
Proposed --> Rejected: Team disagrees
|
||||
Accepted --> Deprecated: No longer relevant
|
||||
Accepted --> Superseded: Replaced by new ADR
|
||||
Deprecated --> [*]
|
||||
Superseded --> [*]
|
||||
Rejected --> [*]
|
||||
```
|
||||
|
||||
### Status Definitions
|
||||
|
||||
- **Proposed**: รอการ Review และ Approve
|
||||
- **Accepted**: ผ่านการ Review แล้ว กำลังใช้งาน
|
||||
- **Deprecated**: เลิกใช้แล้ว แต่ยังอยู่ในระบบ
|
||||
- **Superseded**: ถูกแทนที่โดย ADR อื่น
|
||||
- **Rejected**: ไม่ผ่านการ Approve
|
||||
|
||||
---
|
||||
|
||||
## 📊 ADR Impact Map
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
ADR001[ADR-001<br/>Unified Workflow] --> Corr[Correspondences]
|
||||
ADR001 --> RFA[RFAs]
|
||||
ADR001 --> Circ[Circulations]
|
||||
|
||||
ADR002[ADR-002<br/>Document Numbering] --> Corr
|
||||
ADR002 --> RFA
|
||||
|
||||
ADR003[ADR-003<br/>File Storage] --> Attach[Attachments]
|
||||
ADR003 --> Corr
|
||||
ADR003 --> RFA
|
||||
|
||||
ADR004[ADR-004<br/>RBAC] --> Auth[Authentication]
|
||||
ADR004 --> Guards[Guards]
|
||||
|
||||
ADR005[ADR-005<br/>Tech Stack] --> Backend[Backend]
|
||||
ADR005 --> Frontend[Frontend]
|
||||
ADR005 --> DB[(Database)]
|
||||
|
||||
ADR006[ADR-006<br/>Redis] --> Cache[Caching]
|
||||
ADR006 --> Lock[Locking]
|
||||
ADR006 --> Queue[Job Queue]
|
||||
ADR006 --> ADR002
|
||||
ADR006 --> ADR004
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [System Architecture](../02-architecture/system-architecture.md) - สถาปัตยกรรมระบบโดยรวม
|
||||
- [Data Model](../02-architecture/data-model.md) - โครงสร้างฐานข้อมูล
|
||||
- [API Design](../02-architecture/api-design.md) - การออกแบบ API
|
||||
- [Backend Guidelines](../03-implementation/backend-guidelines.md) - มาตรฐานการพัฒนา Backend
|
||||
- [Frontend Guidelines](../03-implementation/frontend-guidelines.md) - มาตรฐานการพัฒนา Frontend
|
||||
|
||||
---
|
||||
|
||||
## 📝 Review Process
|
||||
|
||||
### Before Merging
|
||||
|
||||
1. สร้าง ADR ใน `specs/05-decisions/ADR-XXX-title.md`
|
||||
2. Update ADR Index ใน `README.md` นี้
|
||||
3. Link ADR ไปยัง Related Documents
|
||||
4. Request Review จากทีม
|
||||
5. อภิปรายและปรับแก้ตาม Feedback
|
||||
6. Update Status เป็น "Accepted"
|
||||
7. Merge to main branch
|
||||
|
||||
### Review Checklist
|
||||
|
||||
- ☐ Context ชัดเจน เข้าใจปัญหา
|
||||
- ☐ มี Options อย่างน้อย 2-3 ทางเลือก
|
||||
- ☐ Pros/Cons ครบถ้วน
|
||||
- ☐ Decision Rationale มีเหตุผลรองรับ
|
||||
- ☐ Consequences ระบุทั้งดีและไม่ดี
|
||||
- ☐ Related ADRs linked ถูกต้อง
|
||||
- ☐ Code examples (ถ้ามี) อ่านง่าย
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### Writing Good ADRs
|
||||
|
||||
1. **Be Concise:** ไม่เกิน 3-4 หน้า (except code examples)
|
||||
2. **Focus on "Why":** อธิบายเหตุผลมากกว่า "How"
|
||||
3. **List Alternatives:** แสดงว่าพิจารณาหลายทางเลือก
|
||||
4. **Be Honest:** ระบุ Cons และ Risks จริงๆ
|
||||
5. **Use Diagrams:** Visualize ด้วย Mermaid diagrams
|
||||
6. **Link References:** ใส่ Link ไปเอกสารอ้างอิง
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
- ❌ เขียนยาวเกินไป (วนเวียน)
|
||||
- ❌ ไม่มี Alternatives (แสดงว่าไม่ได้พิจารณา)
|
||||
- ❌ Consequences ไม่จริงใจ (แต่งว่าดีอย่างเดียว)
|
||||
- ❌ Implementation details มากเกินไป
|
||||
- ❌ ไม่ Update เมื่อ Decision เปลี่ยน
|
||||
|
||||
---
|
||||
|
||||
## 📚 External Resources
|
||||
|
||||
- [ADR GitHub Organization](https://adr.github.io/)
|
||||
- [Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
|
||||
- [ADR Tools](https://github.com/npryce/adr-tools)
|
||||
- [Architecture Decision Records in Action](https://www.thoughtworks.com/insights/blog/architecture/architecture-decision-records-in-action)
|
||||
|
||||
---
|
||||
|
||||
## 📧 Contact
|
||||
|
||||
หากมีคำถามเกี่ยวกับ ADRs กรุณาติดต่อ:
|
||||
|
||||
- **System Architect:** Nattanin Peancharoen
|
||||
- **Development Team Lead:** [Name]
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.5.0
|
||||
**Last Review:** 2025-11-30
|
||||
Reference in New Issue
Block a user