690427:0812 Update Infras #01
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
---
|
||||
name: nestjs-best-practices
|
||||
description: NestJS best practices and architecture patterns for building production-ready applications. This skill should be used when writing, reviewing, or refactoring NestJS code to ensure proper patterns for modules, dependency injection, security, and performance.
|
||||
description: NestJS best practices and architecture patterns for building production-ready LCBP3-DMS backend code. Enforces ADR-009 (no TypeORM migrations), ADR-019 (hybrid UUID), ADR-016 (security), ADR-007 (error handling), ADR-008 (BullMQ), ADR-001/002 (workflow + numbering), ADR-018/020 (AI boundary), and ADR-021 (workflow context).
|
||||
version: 1.8.9
|
||||
scope: backend
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
metadata:
|
||||
author: Kadajett
|
||||
version: '1.1.0'
|
||||
upstream: 'Kadajett/nestjs-best-practices v1.1.0 (forked + LCBP3-aligned)'
|
||||
---
|
||||
|
||||
# NestJS Best Practices
|
||||
@@ -110,6 +112,13 @@ Reference these guidelines when:
|
||||
- `devops-use-logging` - Structured logging
|
||||
- `devops-graceful-shutdown` - Zero-downtime deployments
|
||||
|
||||
### 11. LCBP3-Specific (CRITICAL — Project Overrides)
|
||||
|
||||
- `db-no-typeorm-migrations` — **CRITICAL** ADR-009: edit SQL directly
|
||||
- `lcbp3-workflow-engine` — **CRITICAL** ADR-001/002/021: DSL state machine + double-lock numbering + workflow context
|
||||
- `security-file-two-phase-upload` — **CRITICAL** ADR-016: Upload → Temp → ClamAV → Commit
|
||||
- `lcbp3-ai-boundary` — **CRITICAL** ADR-018/020: Ollama on-prem only, human-in-the-loop
|
||||
|
||||
## NAP-DMS Project-Specific Rules (MUST FOLLOW)
|
||||
|
||||
These rules override general NestJS best practices for the NAP-DMS project:
|
||||
@@ -120,21 +129,62 @@ These rules override general NestJS best practices for the NAP-DMS project:
|
||||
- แก้ไข schema โดยตรงที่: `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
|
||||
- ใช้ n8n workflow สำหรับ data migration ถ้าจำเป็น
|
||||
|
||||
### ADR-019: Hybrid Identifier Strategy (CRITICAL)
|
||||
### ADR-019: Hybrid Identifier Strategy (CRITICAL — March 2026 Pattern)
|
||||
|
||||
> **Updated pattern:** `UuidBaseEntity` exposes `publicId` **directly**. ห้ามใช้ `@Expose({ name: 'id' })` — API จะคืน `publicId` เป็น field name ตรงๆ.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — ใช้ UuidBaseEntity
|
||||
@Entity()
|
||||
export class Project {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude() // ห้ามส่งออกทาง API
|
||||
id: number; // INT AUTO_INCREMENT - internal only
|
||||
export class Project extends UuidBaseEntity {
|
||||
// publicId (string UUIDv7) + id (INT, @Exclude) สืบทอดจาก UuidBaseEntity
|
||||
// API response → { publicId: "019505a1-7c3e-7000-8000-abc123..." }
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
@Expose({ name: 'id' }) // ส่งออกเป็น 'id' ทาง API
|
||||
publicId: string; // UUIDv7 - public API identifier
|
||||
@Column()
|
||||
projectCode: string;
|
||||
|
||||
@Column()
|
||||
projectName: string;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG — pattern เก่า ห้ามใช้
|
||||
@Entity()
|
||||
export class OldProject {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
@Expose({ name: 'id' }) // ❌ อย่า rename publicId เป็น 'id'
|
||||
publicId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**DTO Input (รับ UUID จาก Frontend):**
|
||||
|
||||
```typescript
|
||||
export class CreateContractDto {
|
||||
@IsUUID('7')
|
||||
projectUuid: string; // รับ UUID string จาก client
|
||||
}
|
||||
|
||||
// Controller resolves UUID → INT internally
|
||||
@Post()
|
||||
async create(@Body() dto: CreateContractDto) {
|
||||
const projectId = await this.projectService.resolveInternalId(dto.projectUuid);
|
||||
return this.contractService.create({ ...dto, projectId });
|
||||
}
|
||||
```
|
||||
|
||||
**ห้ามเด็ดขาด (CI Blocker):**
|
||||
|
||||
- ❌ `parseInt(projectPublicId)` — "019505…" → 19 (silently wrong)
|
||||
- ❌ `Number(publicId)` / `+publicId` — NaN
|
||||
- ❌ `@Expose({ name: 'id' })` บน `publicId` (pattern เก่า)
|
||||
- ❌ Expose INT `id` ใน API response (ต้อง `@Exclude()` เสมอ)
|
||||
|
||||
### Two-Phase File Upload
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "1.8.9",
|
||||
"organization": "**NAP-DMS / LCBP3** — Laem Chabang Port Phase 3 Document Management System",
|
||||
"date": "2026-04-22",
|
||||
"abstract": "Comprehensive NestJS best-practices guide compiled for the LCBP3-DMS backend. Contains 40+ rules across 11 categories (10 general + 1 project-specific), prioritized by impact. Forked from Kadajett/nestjs-best-practices (v1.1.0) and aligned to LCBP3 ADRs: ADR-001 (workflow engine), ADR-002 (document numbering), ADR-007 (error handling), ADR-008 (notifications/BullMQ), ADR-009 (no TypeORM migrations), ADR-016 (security), ADR-018/020 (AI boundary), ADR-019 (hybrid UUID identifier — March 2026 pattern), and ADR-021 (workflow context).\n\nThis document is the single, consolidated reference used by Cascade and other AI coding agents when writing, reviewing, or refactoring backend code in this repository. All LCBP3-specific overrides live in section 11.",
|
||||
"references": [
|
||||
"[AGENTS.md (root)](../../../AGENTS.md) — canonical AI agent rules",
|
||||
"[CONTRIBUTING.md](../../../CONTRIBUTING.md) — spec authoring + PR process",
|
||||
"[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-007 Error Handling Strategy](../../../specs/06-Decision-Records/ADR-007-error-handling-strategy.md)",
|
||||
"[ADR-008 Email/Notification Strategy](../../../specs/06-Decision-Records/ADR-008-email-notification-strategy.md)",
|
||||
"[ADR-009 Database Migration Strategy](../../../specs/06-Decision-Records/ADR-009-database-migration-strategy.md)",
|
||||
"[ADR-016 Security & Authentication](../../../specs/06-Decision-Records/ADR-016-security-authentication.md)",
|
||||
"[ADR-018 AI Boundary](../../../specs/06-Decision-Records/ADR-018-ai-boundary.md)",
|
||||
"[ADR-019 Hybrid Identifier Strategy](../../../specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md)",
|
||||
"[ADR-020 AI Intelligence Integration](../../../specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md)",
|
||||
"[ADR-021 Workflow Context](../../../specs/06-Decision-Records/ADR-021-workflow-context.md)",
|
||||
"[Backend Engineering Guidelines](../../../specs/05-Engineering-Guidelines/05-02-backend-guidelines.md)",
|
||||
"[Schema — v1.8.0 Tables](../../../specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql)",
|
||||
"[Data Dictionary](../../../specs/03-Data-and-Storage/03-01-data-dictionary.md)",
|
||||
"Upstream: [Kadajett/nestjs-best-practices](https://github.com/Kadajett/nestjs-best-practices) v1.1.0"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -32,6 +32,7 @@ const CATEGORIES = [
|
||||
{ prefix: 'api-', name: 'API Design', impact: 'MEDIUM', section: 8 },
|
||||
{ prefix: 'micro-', name: 'Microservices', impact: 'MEDIUM', section: 9 },
|
||||
{ prefix: 'devops-', name: 'DevOps & Deployment', impact: 'LOW-MEDIUM', section: 10 },
|
||||
{ prefix: 'lcbp3-', name: 'LCBP3 Project-Specific', impact: 'CRITICAL', section: 11 },
|
||||
];
|
||||
|
||||
interface RuleFrontmatter {
|
||||
@@ -50,8 +51,10 @@ interface Rule {
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: RuleFrontmatter | null; body: string } {
|
||||
// Normalize CRLF → LF so the regex works on Windows-authored files
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
const match = normalized.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: null, body: content };
|
||||
|
||||
Reference in New Issue
Block a user