260222:1053 20260222 refactor specs/ #1 03-Data-and-Storage
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s

This commit is contained in:
admin
2026-02-22 10:53:23 +07:00
parent fd9be92b9d
commit c90a664f53
105 changed files with 2561 additions and 0 deletions

View File

@@ -1,505 +0,0 @@
# 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/02-01-system-architecture.md)
- [File Handling Requirements](../01-requirements/01-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/01-03.10-file-handling.md) - File Handling
- [System Architecture Section 5.2](../02-architecture/02-01-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

@@ -1,423 +0,0 @@
# 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/02-01-system-architecture.md)
- [Access Control Requirements](../01-requirements/01-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/01-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

@@ -1,352 +0,0 @@
# 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/03-02-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