Prepare to version 1.5 use spec-kit
This commit is contained in:
456
specs/03-implementation/backend-guidelines.md
Normal file
456
specs/03-implementation/backend-guidelines.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# Backend Development Guidelines
|
||||
|
||||
**สำหรับ:** NAP-DMS LCBP3 Backend (NestJS + TypeScript)
|
||||
**เวอร์ชัน:** 1.4.5
|
||||
**อัปเดต:** 2025-11-30
|
||||
|
||||
---
|
||||
|
||||
## 🎯 หลักการพื้นฐาน
|
||||
|
||||
ระบบ Backend ของเรามุ่งเน้น **"Data Integrity First"** - ความถูกต้องของข้อมูลต้องมาก่อน ตามด้วย Security และ UX
|
||||
|
||||
### หลักการหลัก
|
||||
|
||||
1. **Strict Typing:** ใช้ TypeScript เต็มรูปแบบ ห้ามใช้ `any`
|
||||
2. **Data Integrity:** ป้องกัน Race Condition ด้วย Optimistic Locking + Redis Lock
|
||||
3. **Security First:** ทุก Endpoint ต้องผ่าน Authentication, Authorization, และ Input Validation
|
||||
4. **Idempotency:** Request สำคัญต้องทำซ้ำได้โดยไม่เกิดผลกระทบซ้ำซ้อน
|
||||
5. **Resilience:** รองรับ Network Failure และ External Service Downtime
|
||||
|
||||
---
|
||||
|
||||
## 📁 โครงสร้างโปรเจกต์
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── common/ # Shared utilities, decorators, guards
|
||||
│ │ ├── auth/ # Authentication module
|
||||
│ │ ├── config/ # Configuration management
|
||||
│ │ ├── decorators/ # Custom decorators
|
||||
│ │ ├── guards/ # Auth guards, RBAC
|
||||
│ │ ├── interceptors/ # Logging, transform, idempotency
|
||||
│ │ └── file-storage/ # Two-phase file storage
|
||||
│ ├── modules/ # Business modules (domain-driven)
|
||||
│ │ ├── user/
|
||||
│ │ ├── project/
|
||||
│ │ ├── correspondence/
|
||||
│ │ ├── rfa/
|
||||
│ │ ├── workflow-engine/
|
||||
│ │ └── ...
|
||||
│ └── database/
|
||||
│ ├── migrations/
|
||||
│ └── seeds/
|
||||
├── test/ # E2E tests
|
||||
└── scripts/ # Utility scripts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Guidelines
|
||||
|
||||
### 1. Authentication & Authorization
|
||||
|
||||
**JWT Authentication:**
|
||||
|
||||
```typescript
|
||||
// ใช้ @UseGuards(JwtAuthGuard) สำหรับ Protected Routes
|
||||
@Controller('projects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectController {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**RBAC (4 ระดับ):**
|
||||
|
||||
```typescript
|
||||
// ใช้ @RequirePermission() Decorator
|
||||
@Post(':id/contracts')
|
||||
@RequirePermission('contract.create', { scope: 'project' })
|
||||
async createContract() {
|
||||
// Level 1: Global Permission
|
||||
// Level 2: Organization Permission
|
||||
// Level 3: Project Permission
|
||||
// Level 4: Contract Permission
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Input Validation
|
||||
|
||||
**ใช้ DTOs พร้อม class-validator:**
|
||||
|
||||
```typescript
|
||||
import { IsNotEmpty, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateCorrespondenceDto {
|
||||
@IsNotEmpty({ message: 'ต้องระบุโปรเจกต์' })
|
||||
@IsUUID('4', { message: 'รูปแบบ Project ID ไม่ถูกต้อง' })
|
||||
project_id: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
title: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Rate Limiting
|
||||
|
||||
```typescript
|
||||
// กำหนด Rate Limit ตาม User Type
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ points: 100, duration: 3600 }) // 100 requests/hour
|
||||
@Post('upload')
|
||||
async uploadFile() { }
|
||||
```
|
||||
|
||||
### 4. Secrets Management
|
||||
|
||||
- **Production:** ใช้ Docker Environment Variables (ไม่ใส่ใน docker-compose.yml)
|
||||
- **Development:** ใช้ `docker-compose.override.yml` (gitignored)
|
||||
- **Validation:** Validate Environment Variables ตอน Start App
|
||||
|
||||
```typescript
|
||||
// src/common/config/env.validation.ts
|
||||
import * as Joi from 'joi';
|
||||
|
||||
export const envValidationSchema = Joi.object({
|
||||
DATABASE_URL: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().min(32).required(),
|
||||
REDIS_URL: Joi.string().required(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Best Practices
|
||||
|
||||
### 1. Optimistic Locking
|
||||
|
||||
**ใช้ @VersionColumn() ป้องกัน Race Condition:**
|
||||
|
||||
```typescript
|
||||
@Entity()
|
||||
export class DocumentNumberCounter {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
last_number: number;
|
||||
|
||||
@VersionColumn() // Auto-increment on update
|
||||
version: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Virtual Columns สำหรับ JSON
|
||||
|
||||
**สร้าง Index สำหรับ JSON field ที่ใช้ Search บ่อย:**
|
||||
|
||||
```sql
|
||||
-- Migration Script
|
||||
ALTER TABLE correspondence_revisions
|
||||
ADD COLUMN ref_project_id INT GENERATED ALWAYS AS
|
||||
(JSON_UNQUOTE(JSON_EXTRACT(details, '$.projectId'))) VIRTUAL;
|
||||
|
||||
CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id);
|
||||
```
|
||||
|
||||
### 3. Soft Delete
|
||||
|
||||
```typescript
|
||||
// Base Entity
|
||||
@Entity()
|
||||
export abstract class BaseEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deleted_at: Date; // NULL = Active, NOT NULL = Soft Deleted
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Core Modules
|
||||
|
||||
### 1. DocumentNumberingModule
|
||||
|
||||
**Double-Lock Mechanism:**
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class DocumentNumberingService {
|
||||
async generateNextNumber(context: NumberingContext): Promise<string> {
|
||||
const lockKey = `doc_num:${context.projectId}:${context.typeId}`;
|
||||
|
||||
// Layer 1: Redis Lock (2-5 seconds TTL)
|
||||
const lock = await this.redisLock.acquire(lockKey, 3000);
|
||||
|
||||
try {
|
||||
// Layer 2: Optimistic DB Lock
|
||||
const counter = await this.counterRepo.findOne({
|
||||
where: context,
|
||||
lock: { mode: 'optimistic' },
|
||||
});
|
||||
|
||||
counter.last_number++;
|
||||
await this.counterRepo.save(counter); // Throws if version changed
|
||||
|
||||
return this.formatNumber(counter);
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. FileStorageService (Two-Phase)
|
||||
|
||||
**Phase 1: Upload to Temp**
|
||||
|
||||
```typescript
|
||||
@Post('upload')
|
||||
async uploadFile(@UploadedFile() file: Express.Multer.File) {
|
||||
// 1. Virus Scan
|
||||
await this.virusScan(file);
|
||||
|
||||
// 2. Save to temp/
|
||||
const tempId = await this.fileStorage.saveToTemp(file);
|
||||
|
||||
// 3. Return temp_id
|
||||
return { temp_id: tempId, expires_at: addHours(new Date(), 24) };
|
||||
}
|
||||
```
|
||||
|
||||
**Phase 2: Commit to Permanent**
|
||||
|
||||
```typescript
|
||||
async createCorrespondence(dto: CreateDto, tempFileIds: string[]) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// 1. Create Correspondence
|
||||
const correspondence = await manager.save(Correspondence, dto);
|
||||
|
||||
// 2. Commit Files (ภายใน Transaction)
|
||||
await this.fileStorage.commitFiles(tempFileIds, correspondence.id, manager);
|
||||
|
||||
return correspondence;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Cleanup Job:**
|
||||
|
||||
```typescript
|
||||
@Cron('0 */6 * * *') // ทุก 6 ชั่วโมง
|
||||
async cleanupOrphanFiles() {
|
||||
const expiredFiles = await this.attachmentRepo.find({
|
||||
where: {
|
||||
is_temporary: true,
|
||||
expires_at: LessThan(new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
for (const file of expiredFiles) {
|
||||
await this.deleteFile(file.file_path);
|
||||
await this.attachmentRepo.remove(file);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Idempotency Interceptor
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class IdempotencyInterceptor implements NestInterceptor {
|
||||
async intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const idempotencyKey = request.headers['idempotency-key'];
|
||||
|
||||
if (!idempotencyKey) {
|
||||
throw new BadRequestException('Idempotency-Key required');
|
||||
}
|
||||
|
||||
// ตรวจสอบ Cache
|
||||
const cached = await this.redis.get(`idempotency:${idempotencyKey}`);
|
||||
if (cached) {
|
||||
return of(JSON.parse(cached)); // Return ผลลัพธ์เดิม
|
||||
}
|
||||
|
||||
// Execute & Cache Result
|
||||
return next.handle().pipe(
|
||||
tap(async (response) => {
|
||||
await this.redis.set(
|
||||
`idempotency:${idempotencyKey}`,
|
||||
JSON.stringify(response),
|
||||
'EX',
|
||||
86400 // 24 hours
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow Engine Integration
|
||||
|
||||
**ห้ามสร้างตาราง Routing แยก** - ใช้ Unified Workflow Engine
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class CorrespondenceWorkflowService {
|
||||
constructor(private workflowEngine: WorkflowEngineService) {}
|
||||
|
||||
async submitCorrespondence(corrId: string, templateId: string) {
|
||||
// สร้าง Workflow Instance
|
||||
const instance = await this.workflowEngine.createInstance({
|
||||
definition_name: 'CORRESPONDENCE_ROUTING',
|
||||
entity_type: 'correspondence',
|
||||
entity_id: corrId,
|
||||
template_id: templateId,
|
||||
});
|
||||
|
||||
// Execute Initial Transition
|
||||
await this.workflowEngine.executeTransition(instance.id, 'SUBMIT');
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing Standards
|
||||
|
||||
### 1. Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('DocumentNumberingService', () => {
|
||||
let service: DocumentNumberingService;
|
||||
let mockRedisLock: jest.Mocked<RedisLock>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
DocumentNumberingService,
|
||||
{ provide: RedisLock, useValue: mockRedisLock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DocumentNumberingService);
|
||||
});
|
||||
|
||||
it('should generate unique numbers concurrently', async () => {
|
||||
// Test concurrent number generation
|
||||
const promises = Array(10)
|
||||
.fill(null)
|
||||
.map(() => service.generateNextNumber(context));
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const unique = new Set(results);
|
||||
|
||||
expect(unique.size).toBe(10); // ไม่มีเลขซ้ำ
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. E2E Tests
|
||||
|
||||
```typescript
|
||||
describe('Correspondence API (e2e)', () => {
|
||||
it('should create correspondence with idempotency', async () => {
|
||||
const idempotencyKey = uuidv4();
|
||||
|
||||
// Request 1
|
||||
const response1 = await request(app.getHttpServer())
|
||||
.post('/correspondences')
|
||||
.set('Idempotency-Key', idempotencyKey)
|
||||
.send(createDto);
|
||||
|
||||
expect(response1.status).toBe(201);
|
||||
|
||||
// Request 2 (Same Key)
|
||||
const response2 = await request(app.getHttpServer())
|
||||
.post('/correspondences')
|
||||
.set('Idempotency-Key', idempotencyKey)
|
||||
.send(createDto);
|
||||
|
||||
expect(response2.status).toBe(201);
|
||||
expect(response2.body.id).toBe(response1.body.id); // Same entity
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Logging & Monitoring
|
||||
|
||||
### 1. Winston Logger
|
||||
|
||||
```typescript
|
||||
// src/modules/monitoring/logger/winston.config.ts
|
||||
export const winstonConfig = {
|
||||
level: process.env.NODE_ENV === 'production' ? 'warn' : 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Audit Logging
|
||||
|
||||
```typescript
|
||||
@Post(':id/approve')
|
||||
@UseInterceptors(AuditLogInterceptor)
|
||||
async approve(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
// AuditLogInterceptor จะบันทึก:
|
||||
// - user_id
|
||||
// - action: 'correspondence.approve'
|
||||
// - entity_type: 'correspondence'
|
||||
// - entity_id: id
|
||||
// - ip_address
|
||||
// - timestamp
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Anti-Patterns (สิ่งที่ห้ามทำ)
|
||||
|
||||
1. ❌ **ห้ามใช้ SQL Triggers** สำหรับ Business Logic
|
||||
2. ❌ **ห้ามใช้ .env** ใน Production (ใช้ Docker ENV)
|
||||
3. ❌ **ห้ามใช้ `any` Type**
|
||||
4. ❌ **ห้าม Hardcode Secrets**
|
||||
5. ❌ **ห้ามสร้างตาราง Routing แยก** (ใช้ Workflow Engine)
|
||||
6. ❌ **ห้ามใช้ console.log** (ใช้ Logger)
|
||||
|
||||
---
|
||||
|
||||
## 📚 เอกสารอ้างอิง
|
||||
|
||||
- [FullStack Guidelines](./fullftack-js-V1.5.0.md)
|
||||
- [Backend Plan v1.4.5](../../docs/2_Backend_Plan_V1_4_5.md)
|
||||
- [Data Dictionary](../../docs/4_Data_Dictionary_V1_4_5.md)
|
||||
- [Workflow Engine Plan](../../docs/2_Backend_Plan_V1_4_4.Phase6A.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Update History
|
||||
|
||||
| Version | Date | Changes |
|
||||
| ------- | ---------- | ---------------------------------- |
|
||||
| 1.0.0 | 2025-11-30 | Initial backend guidelines created |
|
||||
653
specs/03-implementation/frontend-guidelines.md
Normal file
653
specs/03-implementation/frontend-guidelines.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# Frontend Development Guidelines
|
||||
|
||||
**สำหรับ:** NAP-DMS LCBP3 Frontend (Next.js + TypeScript)
|
||||
**เวอร์ชัน:** 1.4.5
|
||||
**อัปเดต:** 2025-11-30
|
||||
|
||||
---
|
||||
|
||||
## 🎯 หลักการพื้นฐาน
|
||||
|
||||
ระบบ Frontend ของเรามุ่งเน้น **User Experience First** - ประสบการณ์ผู้ใช้ที่ราบรื่น รวดเร็ว และใช้งานง่าย
|
||||
|
||||
### หลักการหลัก
|
||||
|
||||
1. **Type Safety:** ใช้ TypeScript Strict Mode ตลอดทั้งโปรเจกต์
|
||||
2. **Responsive Design:** รองรับทุกขนาดหน้าจอ (Mobile-first approach)
|
||||
3. **Performance:** Optimize การโหลดข้อมูล ใช้ Caching อย่างชาญฉลาด
|
||||
4. **Accessibility:** ทุก Component ต้องรองรับ Screen Reader และ Keyboard Navigation
|
||||
5. **Offline Support:** Auto-save Drafts และ Silent Token Refresh
|
||||
|
||||
---
|
||||
|
||||
## 📁 โครงสร้างโปรเจกต์
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (auth)/ # Auth routes (login, register)
|
||||
│ ├── (dashboard)/ # Protected dashboard routes
|
||||
│ ├── api/ # API routes (NextAuth)
|
||||
│ └── layout.tsx
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── custom/ # Custom components
|
||||
│ ├── forms/ # Form components
|
||||
│ ├── layout/ # Layout components (Navbar, Sidebar)
|
||||
│ └── tables/ # Data table components
|
||||
├── lib/
|
||||
│ ├── api/ # API client (Axios)
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── services/ # API service functions
|
||||
│ └── stores/ # Zustand stores
|
||||
├── types/ # TypeScript types & DTOs
|
||||
└── providers/ # Context providers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX Guidelines
|
||||
|
||||
### 1. Design System - Tailwind CSS
|
||||
|
||||
**ใช้ Tailwind Utilities เท่านั้น:**
|
||||
|
||||
```tsx
|
||||
// ✅ Good
|
||||
<div className="flex items-center gap-4 rounded-lg border p-4">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</div>
|
||||
|
||||
// ❌ Bad - Inline styles
|
||||
<div style={{ display: 'flex', padding: '16px' }}>
|
||||
```
|
||||
|
||||
**Responsive Design:**
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="
|
||||
grid
|
||||
grid-cols-1 /* Mobile: 1 column */
|
||||
md:grid-cols-2 /* Tablet: 2 columns */
|
||||
lg:grid-cols-3 /* Desktop: 3 columns */
|
||||
gap-4
|
||||
"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. shadcn/ui Components
|
||||
|
||||
**ใช้ shadcn/ui สำหรับ UI Components:**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>งานของฉัน</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button>สร้างเอกสารใหม่</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Responsive Data Tables
|
||||
|
||||
**Mobile: Card View, Desktop: Table View**
|
||||
|
||||
```tsx
|
||||
export function ResponsiveTable({ data }: { data: Correspondence[] }) {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>เลขที่เอกสาร</TableHead>
|
||||
<TableHead>เรื่อง</TableHead>
|
||||
<TableHead>สถานะ</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.doc_number}</TableCell>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{item.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
เลขที่เอกสาร
|
||||
</div>
|
||||
<div className="font-medium">{item.doc_number}</div>
|
||||
<div className="text-sm text-muted-foreground">เรื่อง</div>
|
||||
<div>{item.title}</div>
|
||||
<Badge>{item.status}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ State Management
|
||||
|
||||
### 1. Server State - TanStack Query
|
||||
|
||||
**ใช้สำหรับข้อมูลจาก API:**
|
||||
|
||||
```tsx
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// Fetch data
|
||||
export function useCorrespondences(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['correspondences', projectId],
|
||||
queryFn: () => correspondenceService.getAll(projectId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Mutation with optimistic update
|
||||
export function useCreateCorrespondence() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: correspondenceService.create,
|
||||
onMutate: async (newCorrespondence) => {
|
||||
// Optimistic update
|
||||
await queryClient.cancelQueries({ queryKey: ['correspondences'] });
|
||||
const previous = queryClient.getQueryData(['correspondences']);
|
||||
|
||||
queryClient.setQueryData(['correspondences'], (old: any) => [
|
||||
...old,
|
||||
newCorrespondence,
|
||||
]);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, newCorrespondence, context) => {
|
||||
// Rollback on error
|
||||
queryClient.setQueryData(['correspondences'], context?.previous);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['correspondences'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Form State - React Hook Form + Zod
|
||||
|
||||
```tsx
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Schema Definition
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'กรุณาระบุหัวเรื่อง').max(500),
|
||||
project_id: z.string().uuid('กรุณาเลือกโปรเจกต์'),
|
||||
type_id: z.string().uuid('กรุณาเลือกประเภทเอกสาร'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
// Form Component
|
||||
export function CorrespondenceForm() {
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
project_id: '',
|
||||
type_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
await createCorrespondence(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>หัวเรื่อง</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">บันทึก</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. UI State - Zustand
|
||||
|
||||
```tsx
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// Draft Store (with localStorage persistence)
|
||||
interface DraftStore {
|
||||
drafts: Record<string, any>;
|
||||
saveDraft: (formKey: string, data: any) => void;
|
||||
loadDraft: (formKey: string) => any;
|
||||
clearDraft: (formKey: string) => void;
|
||||
}
|
||||
|
||||
export const useDraftStore = create<DraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
saveDraft: (formKey, data) =>
|
||||
set((state) => ({
|
||||
drafts: { ...state.drafts, [formKey]: data },
|
||||
})),
|
||||
loadDraft: (formKey) => get().drafts[formKey],
|
||||
clearDraft: (formKey) =>
|
||||
set((state) => {
|
||||
const { [formKey]: _, ...rest } = state.drafts;
|
||||
return { drafts: rest };
|
||||
}),
|
||||
}),
|
||||
{ name: 'correspondence-drafts' }
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Integration
|
||||
|
||||
### 1. Axios Client Setup
|
||||
|
||||
```typescript
|
||||
// lib/api/client.ts
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Request Interceptor - Add Auth & Idempotency
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
// Add JWT Token
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add Idempotency-Key for mutation requests
|
||||
if (['post', 'put', 'delete'].includes(config.method?.toLowerCase() || '')) {
|
||||
config.headers['Idempotency-Key'] = uuidv4();
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response Interceptor - Handle Errors & Token Refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Auto refresh token on 401
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const { data } = await axios.post('/auth/refresh', { refreshToken });
|
||||
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
```
|
||||
|
||||
### 2. Service Layer
|
||||
|
||||
```typescript
|
||||
// lib/services/correspondence.service.ts
|
||||
import apiClient from '@/lib/api/client';
|
||||
import type {
|
||||
Correspondence,
|
||||
CreateCorrespondenceDto,
|
||||
SearchCorrespondenceDto,
|
||||
} from '@/types/dto/correspondence';
|
||||
|
||||
export const correspondenceService = {
|
||||
async getAll(params: SearchCorrespondenceDto): Promise<Correspondence[]> {
|
||||
const { data } = await apiClient.get('/correspondences', { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Correspondence> {
|
||||
const { data } = await apiClient.get(`/correspondences/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(dto: CreateCorrespondenceDto): Promise<Correspondence> {
|
||||
const { data } = await apiClient.post('/correspondences', dto);
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: Partial<CreateCorrespondenceDto>
|
||||
): Promise<Correspondence> {
|
||||
const { data } = await apiClient.put(`/correspondences/${id}`, dto);
|
||||
return data;
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/correspondences/${id}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Dynamic Forms (JSON Schema)
|
||||
|
||||
### Dynamic Form Generator
|
||||
|
||||
```tsx
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface DynamicFormProps {
|
||||
schemaName: string;
|
||||
onSubmit: (data: any) => void;
|
||||
}
|
||||
|
||||
export function DynamicForm({ schemaName, onSubmit }: DynamicFormProps) {
|
||||
// Fetch JSON Schema from Backend
|
||||
const { data: schema } = useQuery({
|
||||
queryKey: ['json-schema', schemaName],
|
||||
queryFn: () => jsonSchemaService.getByName(schemaName),
|
||||
});
|
||||
|
||||
// Generate Zod schema from JSON Schema
|
||||
const zodSchema = useMemo(() => {
|
||||
if (!schema) return null;
|
||||
return generateZodSchemaFromJsonSchema(schema.schema_definition);
|
||||
}, [schema]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(zodSchema!),
|
||||
});
|
||||
|
||||
if (!schema) return <Skeleton />;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{Object.entries(schema.schema_definition.properties).map(
|
||||
([key, prop]: [string, any]) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{prop.title || key}</FormLabel>
|
||||
<FormControl>
|
||||
{renderFieldByType(prop.type, field)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Button type="submit">บันทึก</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to render different field types
|
||||
function renderFieldByType(type: string, field: any) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return <Input {...field} />;
|
||||
case 'number':
|
||||
return <Input type="number" {...field} />;
|
||||
case 'boolean':
|
||||
return <Switch {...field} />;
|
||||
// Add more types as needed
|
||||
default:
|
||||
return <Input {...field} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📤 File Upload
|
||||
|
||||
### Drag & Drop File Upload
|
||||
|
||||
```tsx
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
onUpload: (files: File[]) => void;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
acceptedTypes?: string[];
|
||||
}
|
||||
|
||||
export function FileUploadZone({
|
||||
onUpload,
|
||||
maxFiles = 10,
|
||||
maxSize = 50 * 1024 * 1024, // 50MB
|
||||
acceptedTypes = ['.pdf', '.dwg', '.docx', '.xlsx', '.zip'],
|
||||
}: FileUploadZoneProps) {
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
onUpload(acceptedFiles);
|
||||
},
|
||||
[onUpload]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
accept: acceptedTypes.reduce((acc, type) => ({ ...acc, [type]: [] }), {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
|
||||
transition-colors
|
||||
${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25'
|
||||
}
|
||||
hover:border-primary hover:bg-primary/5
|
||||
`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{isDragActive
|
||||
? 'วางไฟล์ที่นี่...'
|
||||
: 'ลากไฟล์มาวางที่นี่ หรือคลิกเพื่อเลือกไฟล์'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
รองรับ: {acceptedTypes.join(', ')} (สูงสุด {maxFiles} ไฟล์,{' '}
|
||||
{maxSize / 1024 / 1024}MB/ไฟล์)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing Standards
|
||||
|
||||
### 1. Component Testing (Vitest + React Testing Library)
|
||||
|
||||
```tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CorrespondenceForm } from './correspondence-form';
|
||||
|
||||
describe('CorrespondenceForm', () => {
|
||||
it('should validate required fields', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<CorrespondenceForm onSubmit={onSubmit} />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /บันทึก/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(await screen.findByText('กรุณาระบุหัวเรื่อง')).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should submit form with valid data', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<CorrespondenceForm onSubmit={onSubmit} />);
|
||||
|
||||
const titleInput = screen.getByLabelText('หัวเรื่อง');
|
||||
fireEvent.change(titleInput, { target: { value: 'Test Correspondence' } });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /บันทึก/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Test Correspondence' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. E2E Testing (Playwright)
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Correspondence Workflow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="username"]', 'testuser');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/dashboard');
|
||||
});
|
||||
|
||||
test('should create new correspondence', async ({ page }) => {
|
||||
// Navigate to create page
|
||||
await page.click('text=สร้างเอกสาร');
|
||||
await page.waitForURL('/correspondences/new');
|
||||
|
||||
// Fill form
|
||||
await page.fill('input[name="title"]', 'E2E Test Correspondence');
|
||||
await page.selectOption('select[name="project_id"]', { index: 1 });
|
||||
await page.selectOption('select[name="type_id"]', { index: 1 });
|
||||
|
||||
// Submit
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Verify success
|
||||
await expect(page.locator('text=สร้างเอกสารสำเร็จ')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/correspondences\/[a-f0-9-]+/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Anti-Patterns (สิ่งที่ห้ามทำ)
|
||||
|
||||
1. ❌ **ห้ามใช้ Inline Styles** - ใช้ Tailwind เท่านั้น
|
||||
2. ❌ **ห้าม Fetch Data ใน useEffect** - ใช้ TanStack Query
|
||||
3. ❌ **ห้าม Props Drilling** - ใช้ Context หรือ Zustand
|
||||
4. ❌ **ห้าม Any Type**
|
||||
5. ❌ **ห้าม console.log** ใน Production
|
||||
6. ❌ **ห้ามใช้ Index เป็น Key** ใน List
|
||||
7. ❌ **ห้าม Mutation โดยตรง** - ใช้ TanStack Query Mutation
|
||||
|
||||
---
|
||||
|
||||
## 📚 เอกสารอ้างอิง
|
||||
|
||||
- [FullStack Guidelines](./fullftack-js-V1.5.0.md)
|
||||
- [Frontend Plan v1.4.5](../../docs/3_Frontend_Plan_V1_4_5.md)
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
- [TanStack Query](https://tanstack.com/query)
|
||||
- [shadcn/ui](https://ui.shadcn.com)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Update History
|
||||
|
||||
| Version | Date | Changes |
|
||||
| ------- | ---------- | ----------------------------------- |
|
||||
| 1.0.0 | 2025-11-30 | Initial frontend guidelines created |
|
||||
1095
specs/03-implementation/fullftack-js-v1.5.0.md
Normal file
1095
specs/03-implementation/fullftack-js-v1.5.0.md
Normal file
File diff suppressed because it is too large
Load Diff
0
specs/03-implementation/testing-strategy.md
Normal file
0
specs/03-implementation/testing-strategy.md
Normal file
Reference in New Issue
Block a user