Main: revise specs to 1.5.0 (completed)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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