690427:0812 Update Infras #01
CI / CD Pipeline / build (push) Successful in 5m51s
CI / CD Pipeline / deploy (push) Successful in 2m9s

This commit is contained in:
2026-04-27 08:12:28 +07:00
parent 9384581aee
commit a57fef4d44
68 changed files with 9750 additions and 468 deletions
@@ -5,20 +5,22 @@ impactDescription: Use INT PK internally + UUID for public API per project ADR-0
tags: database, uuid, identifier, adr-019, api-design, typeorm
---
## Hybrid Identifier Strategy (ADR-019)
## Hybrid Identifier Strategy (ADR-019) — March 2026 Pattern
**This project follows ADR-019: INT Primary Key (internal) + UUIDv7 (public API)**
Unlike standard practices that use UUID as the primary key, this project uses a **hybrid approach** optimized for MariaDB performance and API consistency.
> **Updated pattern (March 2026):** Entities extend `UuidBaseEntity`. The `publicId` column is exposed **directly** in API responses — ห้ามใช้ `@Expose({ name: 'id' })` เพื่อ rename.
### The Strategy
| Layer | Field | Type | Usage |
|-------|-------|------|-------|
| **Database PK** | `id` | `INT AUTO_INCREMENT` | Internal foreign keys only |
| **Public API** | `uuid` | `MariaDB UUID` (native) | External references, URLs |
| **DTO Input** | `xxxUuid` | `string` | Accept UUID in create/update |
| **DTO Output** | `id` | `string` | API returns UUID as `id` via `@Expose` |
| Layer | Field | Type | Usage |
| --------------- | ---------- | ----------------------------------- | ------------------------------------------------- |
| **Database PK** | `id` | `INT AUTO_INCREMENT` | Internal foreign keys only (marked `@Exclude()`) |
| **Public API** | `publicId` | `MariaDB UUID` (native, BINARY(16)) | External references, URLs — exposed as-is |
| **DTO Input** | `xxxUuid` | `string` (UUIDv7) | Accept UUID in create/update DTOs |
| **DTO Output** | `publicId` | `string` (UUIDv7) | API returns `publicId` field directly (no rename) |
### Why Hybrid IDs?
@@ -27,31 +29,51 @@ Unlike standard practices that use UUID as the primary key, this project uses a
- **Compatibility**: UUID works well with distributed systems and external integrations
- **MariaDB Native**: Uses MariaDB's native UUID type (stored as BINARY(16), auto-converts to string)
### Entity Definition
### Entity Definition (Current Pattern)
```typescript
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
import { Exclude, Expose } from 'class-transformer';
import { Entity, Column } from 'typeorm';
import { UuidBaseEntity } from '@/common/entities/uuid-base.entity';
@Entity('contracts')
export class Contract {
@PrimaryGeneratedColumn()
@Exclude() // Never expose in API response
id: number; // Internal INT PK - used for FK relationships
@Column({ type: 'uuid', unique: true })
@Expose({ name: 'id' }) // Exposed as 'id' in API
uuid: string; // Public UUIDv7 - what API consumers see
export class Contract extends UuidBaseEntity {
// publicId (string UUIDv7) + id (INT, @Exclude) สืบทอดจาก UuidBaseEntity
// API response → { publicId: "019505a1-7c3e-7000-8000-abc123...", contractCode: ..., ... }
@Column()
contractCode: string;
@Column()
contractName: string;
@Column({ name: 'project_id' })
projectId: number; // INT FK — internal, not exposed if marked @Exclude in UuidBaseEntity
}
```
### DTO Pattern (Accept UUID, Resolve to INT)
**`UuidBaseEntity` (shared base):**
```typescript
import { PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Exclude } from 'class-transformer';
export abstract class UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude() // ❗ CRITICAL: INT id must never leak to API
id: number;
@Column({ type: 'uuid', unique: true, generated: 'uuid' })
publicId: string; // UUIDv7, exposed as-is
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
```
### DTO Pattern (Accept UUID, Resolve to INT Internally)
```typescript
// dto/create-contract.dto.ts
@@ -59,8 +81,8 @@ import { IsUUID, IsNotEmpty } from 'class-validator';
export class CreateContractDto {
@IsNotEmpty()
@IsUUID('4')
projectUuid: string; // Accept UUID from client
@IsUUID('7') // UUIDv7 (MariaDB native)
projectUuid: string; // Accept UUID from client
@IsNotEmpty()
contractCode: string;
@@ -69,48 +91,38 @@ export class CreateContractDto {
contractName: string;
}
// dto/contract-response.dto.ts
import { Exclude, Expose } from 'class-transformer';
export class ContractResponseDto {
@Expose({ name: 'id' })
uuid: string; // Returned as 'id' field in JSON
contractCode: string;
contractName: string;
}
// ❌ NO Response DTO with @Expose rename needed.
// Entity class_transformer via TransformInterceptor will serialize publicId directly.
```
### Service/Controller Pattern
```typescript
@Controller('contracts')
@UseGuards(JwtAuthGuard, CaslAbilityGuard)
export class ContractsController {
constructor(
private contractsService: ContractsService,
private uuidResolver: UuidResolver, // Helper to convert UUID → INT
private uuidResolver: UuidResolver
) {}
@Post()
async create(@Body() dto: CreateContractDto) {
// Resolve UUID to INT PK for database operations
// Resolve UUID INT PK for FK relationship
const projectId = await this.uuidResolver.resolveProject(dto.projectUuid);
// Create with INT FK
const contract = await this.contractsService.create({
...dto,
projectId, // INT for database
projectId,
});
// Response automatically transforms via @Expose
// Response: TransformInterceptor + @Exclude on id → publicId exposed directly
return contract;
}
@Get(':id')
async findOne(@Param('id') uuid: string) {
// Controller receives UUID string
// Service handles UUID → INT resolution internally
return this.contractsService.findByUuid(uuid);
@Get(':publicId')
async findOne(@Param('publicId', ParseUuidPipe) publicId: string) {
return this.contractsService.findOneByPublicId(publicId);
}
}
```
@@ -124,21 +136,21 @@ export class UuidResolver {
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(Contract)
private contractRepo: Repository<Contract>,
private contractRepo: Repository<Contract>
) {}
async resolveProject(uuid: string): Promise<number> {
async resolveProject(publicId: string): Promise<number> {
const project = await this.projectRepo.findOne({
where: { uuid },
select: ['id'], // Only fetch INT PK
where: { publicId },
select: ['id'], // Only INT PK for FK
});
if (!project) throw new NotFoundException('Project not found');
return project.id;
}
async resolveContract(uuid: string): Promise<number> {
async resolveContract(publicId: string): Promise<number> {
const contract = await this.contractRepo.findOne({
where: { uuid },
where: { publicId },
select: ['id'],
});
if (!contract) throw new NotFoundException('Contract not found');
@@ -147,20 +159,20 @@ export class UuidResolver {
}
```
### TransformInterceptor (Required)
### TransformInterceptor (Required — register ONCE)
```typescript
// Must be configured globally to handle @Exclude/@Expose
// Register via APP_INTERCEPTOR in CommonModule — ห้ามซ้ำใน main.ts
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => instanceToPlain(data)), // Applies class-transformer decorators
map((data) => instanceToPlain(data)) // Applies @Exclude / @Expose
);
}
}
// app.module.ts
// common.module.ts
@Module({
providers: [
{
@@ -169,40 +181,42 @@ export class TransformInterceptor implements NestInterceptor {
},
],
})
export class AppModule {}
export class CommonModule {}
```
> **Warning:** ห้ามเรียก `app.useGlobalInterceptors(new TransformInterceptor())` ใน `main.ts` ซ้ำ — จะทำให้ response double-wrap `{ data: { data: ... } }`.
### Critical: NEVER ParseInt on UUID
```typescript
// ❌ WRONG - parseInt on UUID gives garbage value
const id = parseInt(projectUuid); // "0195a1b2-..." → 195 (wrong!)
const id = parseInt(projectPublicId); // "0195a1b2-..." → 195 (wrong!)
// ❌ WRONG - Number() on UUID
const id = Number(projectUuid); // NaN
const id = Number(projectPublicId); // NaN
// ❌ WRONG - Unary plus on UUID
const id = +projectUuid; // NaN
const id = +projectPublicId; // NaN
// ✅ CORRECT - Resolve via database lookup
const projectId = await uuidResolver.resolveProject(projectUuid);
const projectId = await uuidResolver.resolveProject(projectPublicId);
// ✅ CORRECT - Use TypeORM find with UUID column
const project = await projectRepo.findOne({ where: { uuid: projectUuid } });
const id = project.id; // Get INT PK from entity
// ✅ CORRECT - Use TypeORM find with publicId column
const project = await projectRepo.findOne({ where: { publicId: projectPublicId } });
const id = project.id; // Get INT PK from entity
```
### Query with UUID (No Resolution Needed)
### Query with publicId (No Resolution Needed)
```typescript
// Direct UUID lookup in TypeORM
const project = await this.projectRepo.findOne({
where: { uuid: projectUuid }, // Query by UUID column
where: { publicId: projectPublicId },
});
// Relations use INT FK internally
const contracts = await this.contractRepo.find({
where: { projectId: project.id }, // INT for FK query
where: { projectId: project.id }, // INT for FK query
});
```
@@ -0,0 +1,100 @@
---
title: No TypeORM Migrations (ADR-009)
impact: CRITICAL
impactDescription: Edit SQL schema files directly; n8n handles data migration. Do not generate TypeORM migration files.
tags: database, schema, migration, adr-009, sql, n8n
---
## No TypeORM Migrations (ADR-009)
**This project does NOT use TypeORM migration files.**
All schema changes must be made **directly** in the canonical SQL file:
- `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
Delta scripts (for incremental rollout to existing environments) go under:
- `specs/03-Data-and-Storage/deltas/YYYY-MM-DD-descriptive-name.sql`
Data migration (e.g., backfilling a new column) is handled by **n8n workflows**, not TypeORM's `QueryRunner`.
---
## Why No Migrations?
1. **Single source of truth** — The full SQL schema is always readable as one file. No need to replay a migration chain to understand current state.
2. **Review friendly** — Schema diff = git diff on the SQL file. Reviewers see the complete picture.
3. **Ops alignment** — DBAs and operators work in SQL, not TypeScript.
4. **n8n for data** — Business-meaningful data transforms live in n8n where they can be versioned, retried, and orchestrated with monitoring.
---
## ✅ Workflow for a Schema Change
1. **Update Data Dictionary** first:
- `specs/03-Data-and-Storage/03-01-data-dictionary.md` — add field meaning + business rules.
2. **Update the canonical schema**:
- Edit `lcbp3-v1.8.0-schema-02-tables.sql` — add/alter column, constraint, index.
3. **Add a delta script** (if deploying to existing env):
- `specs/03-Data-and-Storage/deltas/2026-04-22-add-rfa-revision-column.sql`
```sql
-- Delta: Add revision column to rfa table
ALTER TABLE rfa
ADD COLUMN revision INT NOT NULL DEFAULT 1 AFTER status;
CREATE INDEX idx_rfa_revision ON rfa(revision);
```
4. **Update the Entity** (`backend/src/.../entities/rfa.entity.ts`):
```typescript
@Column({ type: 'int', default: 1 })
revision: number;
```
5. **If data backfill needed** → create n8n workflow, not TypeScript migration.
---
## ❌ Forbidden
```bash
# ❌ DO NOT generate migrations
pnpm typeorm migration:generate ./src/migrations/AddRevision
# ❌ DO NOT run migrations
pnpm typeorm migration:run
```
```typescript
// ❌ DO NOT write migration classes
export class AddRevision1730000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> { /* ... */ }
async down(queryRunner: QueryRunner): Promise<void> { /* ... */ }
}
```
---
## ✅ TypeORM Config (runtime only)
```typescript
// ormconfig.ts
export default {
type: 'mariadb',
// ...
synchronize: false, // ❗ NEVER true (would auto-sync entity ↔ schema)
migrationsRun: false, // ❗ NEVER true
// ❌ Do NOT specify `migrations:` entries
};
```
`synchronize: false` is mandatory because the canonical SQL file is authoritative — TypeORM should never mutate the schema.
---
## Reference
- [ADR-009 Database Migration Strategy](../../../../specs/06-Decision-Records/ADR-009-database-migration-strategy.md)
- [Data Dictionary](../../../../specs/03-Data-and-Storage/03-01-data-dictionary.md)
- [Schema Tables](../../../../specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql)
@@ -0,0 +1,157 @@
---
title: AI Integration Boundary (ADR-018 / ADR-020)
impact: CRITICAL
impactDescription: AI runs on Admin Desktop only; AI → DMS API → DB (never direct); human-in-the-loop validation mandatory; full audit trail.
tags: ai, ollama, boundary, adr-018, adr-020, privacy, audit
---
## AI Integration Boundary
LCBP3 uses **on-premises AI only** (Ollama on Admin Desktop) with strict isolation from data layers.
---
## The Boundary
```
┌────────────────────────────────────────────────────────────┐
│ User Browser (Next.js) │
└─────────────────────────┬──────────────────────────────────┘
│ (authenticated HTTPS)
┌─────────────────────────▼──────────────────────────────────┐
│ DMS API (NestJS) ◀── enforces CASL, validation, audit │
│ ├─ AiGateway (proxies to Ollama) │
│ └─ DB + Storage (Elasticsearch, MariaDB, File System) │
└─────────────────────────┬──────────────────────────────────┘
│ (HTTP → Admin Desktop, internal)
┌─────────────────────────▼──────────────────────────────────┐
│ Admin Desktop (Desk-5439) │
│ ├─ Ollama (Gemma 4) │
│ ├─ PaddleOCR (Thai + English) │
│ └─ n8n orchestration │
└────────────────────────────────────────────────────────────┘
```
**❗ Admin Desktop has NO network access to MariaDB, no SMB to storage, no shared secrets.** It receives base64-encoded file bytes over HTTPS and returns extracted text + suggestions.
---
## Required Patterns
### 1. AiGateway Module (backend)
```typescript
@Module({
controllers: [AiController],
providers: [AiService, AiGateway, AiAuditLogger],
exports: [AiService],
})
export class AiModule {}
@Injectable()
export class AiService {
async extractMetadata(fileId: number, user: User): Promise<ExtractedMetadata> {
// 1. Authorize (CASL: user can read this file)
await this.ability.ensureCan(user, 'read', File, fileId);
// 2. Load file (DMS API, inside the boundary)
const fileBytes = await this.storageService.read(fileId);
// 3. Call Admin Desktop AI over HTTP
const raw = await this.aiGateway.extract(fileBytes);
// 4. Validate AI output schema (Zod)
const parsed = ExtractedMetadataSchema.parse(raw);
// 5. Audit log (who, what, when, model, confidence)
await this.auditLogger.log({
userId: user.id,
action: 'ai.extract_metadata',
fileId,
model: raw.model,
confidence: parsed.confidence,
});
// 6. Return — frontend MUST render for human confirmation
return parsed;
}
}
```
### 2. Human-in-the-Loop
AI output is **never persisted directly**. Users must confirm via `DocumentReviewForm`:
```tsx
<DocumentReviewForm
document={doc}
aiSuggestions={suggestions}
onConfirm={(reviewed) => saveMetadata(reviewed)} // user edits applied
/>
```
The `user_confirmed_at` timestamp and diff (AI suggestion → final value) are stored in the audit log.
### 3. Rate Limiting
```typescript
@Post('ai/extract')
@UseGuards(JwtAuthGuard, CaslAbilityGuard, ThrottlerGuard)
@Throttle({ default: { limit: 10, ttl: 60_000 } }) // 10 req/min/user
async extract(@Body() dto: ExtractDto) { /* ... */ }
```
---
## ❌ Forbidden
```typescript
// ❌ AI container connecting to DB
// docker-compose.yml inside ai-service:
// environment:
// DATABASE_URL: mysql://... ← NEVER
// ❌ AI SDK calling cloud API
import OpenAI from 'openai'; // ❌ No cloud AI SDKs in production code
const client = new OpenAI({ apiKey: ... });
// ❌ Persisting AI output without human confirm
async extractAndSave(fileId: number) {
const metadata = await this.ai.extract(fileId);
await this.repo.save({ fileId, ...metadata }); // ❌ skips human review
}
// ❌ Skipping audit log
const result = await this.aiGateway.extract(bytes); // no logging
return result;
```
---
## Audit Log Schema
```sql
CREATE TABLE ai_audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
public_id UUID UNIQUE NOT NULL,
user_id INT NOT NULL,
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
file_id INT,
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
confidence DECIMAL(4,3),
input_hash CHAR(64), -- SHA-256 of input for replay detection
output_summary JSON,
human_confirmed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_created (user_id, created_at),
INDEX idx_file (file_id)
);
```
---
## Reference
- [ADR-018 AI Boundary](../../../../specs/06-Decision-Records/ADR-018-ai-boundary.md)
- [ADR-020 AI Intelligence Integration](../../../../specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md)
- [ADR-017 Ollama Data Migration](../../../../specs/06-Decision-Records/ADR-017-ollama-data-migration.md)
@@ -0,0 +1,181 @@
---
title: Workflow Engine + Document Numbering + Workflow Context (ADR-001 / 002 / 021)
impact: CRITICAL
impactDescription: DSL-based state machine; double-lock numbering; integrated workflow context exposed to clients.
tags: workflow, numbering, redlock, version-column, adr-001, adr-002, adr-021
---
## Workflow Engine + Numbering + Context
LCBP3 uses a **unified workflow engine** (DSL-based state machine) across RFA, Transmittal, Correspondence, Circulation, and Shop Drawing. Every state transition goes through the same engine — no per-type routing tables.
---
## ADR-001: Unified Workflow Engine
### State Transition Pattern
```typescript
@Injectable()
export class WorkflowEngine {
async transition(
instanceId: string,
action: WorkflowAction,
actor: User,
context?: WorkflowContext,
): Promise<WorkflowInstance> {
// 1. Load current state from DB (never trust client-provided state)
const instance = await this.repo.findOneByPublicId(instanceId);
if (!instance) throw new NotFoundException();
// 2. Validate transition against DSL
const dsl = await this.dslService.load(instance.workflowTypeId);
const nextState = dsl.resolve(instance.currentState, action);
if (!nextState) {
throw new BusinessException(
`Action ${action} not allowed from state ${instance.currentState}`,
'ไม่สามารถดำเนินการนี้ได้ในสถานะปัจจุบัน',
'กรุณาตรวจสอบขั้นตอนการอนุมัติ',
'WF_INVALID_TRANSITION',
);
}
// 3. Apply transition atomically (optimistic lock via @VersionColumn)
instance.currentState = nextState;
await this.repo.save(instance); // throws OptimisticLockVersionMismatchError on race
// 4. Emit event for listeners (notifications via BullMQ — ADR-008)
this.eventBus.publish(new WorkflowTransitionedEvent(instance, action, actor));
return instance;
}
}
```
### ❌ Anti-Patterns
- ❌ Hard-coded `switch (state)` in controllers/services
- ❌ Trusting `currentState` from request body
- ❌ Creating separate routing tables per document type
---
## ADR-002: Document Numbering (Double-Lock)
Concurrent requests for a new document number **must** use both:
1. **Redis Redlock** — distributed lock across app instances
2. **TypeORM `@VersionColumn`** — optimistic lock on counter row
### Counter Entity
```typescript
@Entity('document_number_counters')
@Unique(['projectId', 'documentTypeId'])
export class DocumentNumberCounter extends UuidBaseEntity {
@Column({ name: 'project_id' })
projectId: number;
@Column({ name: 'document_type_id' })
documentTypeId: number;
@Column({ name: 'last_number', default: 0 })
lastNumber: number;
@VersionColumn()
version: number; // ❗ Optimistic lock — do not rename, do not remove
}
```
### Service Pattern
```typescript
@Injectable()
export class DocumentNumberingService {
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
private redlock: RedlockService,
private readonly logger: Logger,
) {}
async generateNext(ctx: NumberingContext): Promise<string> {
const lockKey = `doc_num:${ctx.projectId}:${ctx.documentTypeId}`;
// Distributed lock — 3s TTL, up to 5 retries
const lock = await this.redlock.acquire([lockKey], 3000);
try {
// Optimistic lock via @VersionColumn
const counter = await this.counterRepo.findOne({
where: { projectId: ctx.projectId, documentTypeId: ctx.documentTypeId },
});
if (!counter) {
throw new NotFoundException('Counter not initialized for this project/type');
}
counter.lastNumber += 1;
await this.counterRepo.save(counter); // may throw OptimisticLockVersionMismatchError
return this.formatNumber(ctx, counter.lastNumber);
} catch (err) {
if (err instanceof OptimisticLockVersionMismatchError) {
this.logger.warn(`Numbering race detected for ${lockKey}, retrying`);
// Let caller retry via BullMQ retry policy
}
throw err;
} finally {
await lock.release();
}
}
private formatNumber(ctx: NumberingContext, seq: number): string {
// e.g. "LCBP3-RFA-0042"
return `${ctx.projectCode}-${ctx.typeCode}-${String(seq).padStart(4, '0')}`;
}
}
```
### ❌ Anti-Patterns
- ❌ App-side counter only (`let counter = 0; counter++`)
- ❌ Using `findOne` + `update` without `@VersionColumn`
- ❌ Using only Redis lock without DB optimistic lock (race if Redis fails)
---
## ADR-021: Integrated Workflow Context
Every workflow-aware API response **must** expose:
```typescript
export class WorkflowEnvelope<T> {
data: T;
workflow: {
instancePublicId: string;
currentState: string; // e.g. 'pending_review'
availableActions: string[]; // e.g. ['approve', 'reject', 'request-revision']
canEdit: boolean; // computed from CASL + current state
lastTransitionAt: string; // ISO 8601
};
stepAttachments?: Array<{ // files produced by the current/previous step
publicId: string;
fileName: string;
stepCode: string;
downloadUrl: string;
}>;
}
```
Frontend uses `workflow.availableActions` to render buttons — no client-side state machine logic.
---
## Reference
- [ADR-001 Unified Workflow Engine](../../../../specs/06-Decision-Records/ADR-001-unified-workflow-engine.md)
- [ADR-002 Document Numbering Strategy](../../../../specs/06-Decision-Records/ADR-002-document-numbering-strategy.md)
- [ADR-021 Workflow Context](../../../../specs/06-Decision-Records/ADR-021-workflow-context.md)
@@ -0,0 +1,137 @@
---
title: Two-Phase File Upload + ClamAV (ADR-016)
impact: CRITICAL
impactDescription: Upload → Temp → ClamAV scan → Commit → Permanent. Whitelist + 50MB cap. StorageService only.
tags: file-upload, clamav, security, adr-016, storage
---
## Two-Phase File Upload (ADR-016)
**Never write uploaded files directly to permanent storage.** All uploads must go through:
```
Client → Upload endpoint → Temp storage → ClamAV scan → Commit endpoint → Permanent storage
```
---
## Constraints (non-negotiable)
| Rule | Value |
| --- | --- |
| Allowed MIME types | `application/pdf`, `image/vnd.dwg`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `application/zip` |
| Allowed extensions | `.pdf`, `.dwg`, `.docx`, `.xlsx`, `.zip` |
| Max size | 50 MB |
| Temp TTL | 24 h (purged by cron) |
| Virus scan | ClamAV (blocking) |
| Mover | `StorageService` only — never `fs.rename` directly from controller |
---
## Phase 1: Upload to Temp
```typescript
@Post('upload')
@UseGuards(JwtAuthGuard, ThrottlerGuard)
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
}))
async uploadTemp(
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: User,
): Promise<{ tempId: string; expiresAt: string }> {
// 1. Validate MIME + extension (defense in depth)
this.fileValidator.assertAllowed(file);
// 2. Scan with ClamAV
const scanResult = await this.clamavService.scan(file.buffer);
if (!scanResult.clean) {
throw new BusinessException(
`ClamAV rejected: ${scanResult.signature}`,
'ไฟล์ไม่ปลอดภัย ระบบตรวจพบความเสี่ยง',
'กรุณาตรวจสอบไฟล์และลองใหม่อีกครั้ง',
'FILE_INFECTED',
);
}
// 3. Save to temp (encrypted at rest)
const tempId = await this.storageService.saveToTemp(file, user.id);
return {
tempId,
expiresAt: addHours(new Date(), 24).toISOString(),
};
}
```
---
## Phase 2: Commit in Transaction
The business operation (e.g., creating a Correspondence) promotes temp files to permanent **in the same DB transaction**.
```typescript
async createCorrespondence(dto: CreateCorrespondenceDto, user: User) {
return this.dataSource.transaction(async (manager) => {
// 1. Create domain entity
const entity = await manager.save(Correspondence, {
...dto,
createdById: user.id,
});
// 2. Commit temp files → permanent (ACID together with entity)
await this.storageService.commitFiles(
dto.tempFileIds,
{ entityId: entity.id, entityType: 'correspondence' },
manager,
);
return entity;
});
}
```
If the transaction rolls back, temp files remain and expire in 24h — no orphaned permanent files.
---
## StorageService Contract
```typescript
export interface StorageService {
saveToTemp(file: Express.Multer.File, ownerId: number): Promise<string>;
commitFiles(
tempIds: string[],
target: { entityId: number; entityType: string },
manager: EntityManager,
): Promise<FileRecord[]>;
purgeExpiredTemp(): Promise<number>; // called by cron
getPermanentPath(fileId: number): Promise<string>;
}
```
---
## ❌ Forbidden
```typescript
// ❌ Direct write to permanent
fs.writeFileSync(`/var/storage/${file.originalname}`, file.buffer);
// ❌ Skip ClamAV
await this.storageService.savePermanent(file);
// ❌ Non-whitelist MIME
@UseInterceptors(FileInterceptor('file')) // no size or type limit
// ❌ Commit outside transaction
const entity = await this.repo.save(...);
await this.storageService.commitFiles(tempIds, ...); // race: entity exists, files may fail
```
---
## Reference
- [ADR-016 Security & Authentication](../../../../specs/06-Decision-Records/ADR-016-security-authentication.md)
- [Edge Cases](../../../../specs/01-Requirements/01-06-edge-cases-and-rules.md) — file upload scenarios