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
+49 -15
View File
@@ -1,6 +1,8 @@
---
name: next-best-practices
description: Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
description: Next.js best practices for LCBP3-DMS frontend. Enforces ADR-019 (publicId only, no parseInt/id fallback), TanStack Query + RHF + Zod, shadcn/ui, i18n, ADR-007 error UX, ADR-021 IntegratedBanner/WorkflowLifecycle, two-phase file upload.
version: 1.8.9
scope: frontend
user-invocable: false
---
@@ -157,6 +159,24 @@ See [parallel-routes.md](./parallel-routes.md) for:
- `default.tsx` for fallbacks
- Closing modals correctly with `router.back()`
## i18n (Thai / English)
See [i18n.md](./i18n.md) for:
- `useTranslations('namespace')` pattern
- Key naming (kebab-case, feature-namespaced)
- When Zod messages stay inline vs i18n
- Server-side `userMessage` passthrough
## Two-Phase File Upload
See [two-phase-upload.md](./two-phase-upload.md) for:
- `useDropzone` + `useMutation` hook
- `tempFileIds` form-state pattern
- Whitelist MIME / max-size (must mirror backend)
- Clear-on-submit / expired-temp handling
## Self-Hosting
See [self-hosting.md](./self-hosting.md) for:
@@ -204,28 +224,38 @@ const form = useForm({
});
```
### ADR-019 UUID Handling (CRITICAL)
### ADR-019 UUID Handling (CRITICAL — March 2026 Pattern)
> **Updated:** ใช้ `publicId` ตรงๆ — ห้ามใช้ `id ?? ''` fallback หรือ `uuid` ร่วม.
```tsx
// Interface ต้องมีทั้ง id และ publicId
// ✅ CORRECT — Interface มีแค่ publicId
interface Contract {
id?: number; // Internal (อาจ undefined)
publicId?: string; // UUID - ใช้ตัวนี้
publicId?: string; // UUID from API — ใช้ตัวนี้
contractCode: string;
contractName: string;
}
// Select options - ใช้ pattern นี้เสมอ
// ✅ CORRECT — Select options (ไม่มี fallback)
const options = contracts.map((c) => ({
label: `${c.contractName} (${c.contractCode})`,
value: String(c.publicId ?? c.id ?? ''), // fallback pattern
key: String(c.publicId ?? c.id ?? ''),
value: c.publicId ?? '', // ใช้ publicId ล้วน
key: c.publicId ?? c.contractCode, // fallback ไป business field ได้
}));
// ❌ ห้ามใช้ parseInt บน UUID
// const id = parseInt(projectId); // WRONG!
// ❌ WRONG — pattern เก่า (ห้าม)
interface OldContract {
id?: number; // ❌ อย่า expose INT id
uuid?: string; // ❌ ใช้ชื่อ uuid
publicId?: string;
}
const oldValue = String(c.publicId ?? c.id ?? ''); // ❌ `id ?? ''` fallback ห้าม
// ✅ ส่ง UUID string ตรงๆ
apiClient.get(`/projects/${projectId}`); // projectId is UUID string
// ❌ NEVER parseInt on UUID
// const badId = parseInt(projectPublicId); // "019505..." → 19 (WRONG!)
// ✅ ส่ง UUID string ตรงๆ ไป API
apiClient.get(`/projects/${projectPublicId}`);
```
### Naming Conventions
@@ -312,13 +342,17 @@ apiClient.interceptors.request.use((config) => {
### Anti-Patterns (ห้ามทำ)
- ❌ Fetch data ใน useEffect โดยตรง
- ❌ Fetch data ใน useEffect โดยตรง (ใช้ TanStack Query)
- ❌ Props drilling ลึกเกิน 3 levels
- ❌ Inline styles (ใช้ Tailwind)
- ❌ console.log ใน production
- ❌ parseInt() บน UUID values
-`console.log` ใน committed code
-`parseInt()` / `Number()` / `+` บน UUID values (ADR-019)
-`id ?? ''` fallback บน `publicId` (ใช้ `publicId ?? ''` หรือ fallback ไป business field)
- ❌ Expose `uuid` คู่กับ `publicId` ใน interface (ใช้ `publicId` อย่างเดียว)
- ❌ ใช้ index เป็น key ใน list
- ❌ Snake_case ใน form field names (ใช้ camelCase)
- ❌ Hardcode Thai/English string ใน component (ใช้ i18n keys)
-`any` type (strict mode)
---
@@ -0,0 +1,79 @@
# i18n (Thai / English)
LCBP3 frontend **must not** hardcode Thai or English UI strings in components.
## Rules
1. **All user-facing strings go through the i18n layer** (`next-intl` / `i18next` — check `frontend/package.json`).
2. **Keys use kebab-case**, namespaced by feature:
- `correspondence.list.title`
- `correspondence.form.submit`
- `common.actions.cancel`
3. **Comments in code remain Thai** (business logic explanation); **only UI copy** goes through i18n.
4. **Error messages** from backend (via ADR-007 `userMessage`) are already localized server-side — render them directly, don't translate client-side.
---
## ❌ Wrong
```tsx
export function CorrespondenceHeader() {
return <h1></h1>; // ❌ hardcoded Thai
}
toast.success('บันทึกสำเร็จ'); // ❌ hardcoded
```
---
## ✅ Right
```tsx
import { useTranslations } from 'next-intl';
export function CorrespondenceHeader() {
const t = useTranslations('correspondence.list');
return <h1>{t('title')}</h1>;
}
toast.success(t('save.success'));
```
Translation files:
```json
// messages/th.json
{
"correspondence": {
"list": { "title": "รายการหนังสือติดต่อ" },
"save": { "success": "บันทึกสำเร็จ" }
}
}
// messages/en.json
{
"correspondence": {
"list": { "title": "Correspondence List" },
"save": { "success": "Saved successfully" }
}
}
```
---
## Zod Error Messages
Zod error messages shown in forms **do** stay in Thai inline (per `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md`), because they're schema-bound and rarely need translation. If dual-language support becomes required, wrap with an i18n-aware resolver:
```ts
const schema = z.object({
projectUuid: z.string().uuid(t('validation.project.required')),
});
```
---
## Reference
- [i18n Guidelines](../../../specs/05-Engineering-Guidelines/05-08-i18n-guidelines.md)
- [Frontend Guidelines](../../../specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md)
@@ -0,0 +1,100 @@
# Two-Phase File Upload (Frontend)
Pair with [backend two-phase upload rule](../nestjs-best-practices/rules/security-file-two-phase-upload.md).
## Flow
```
User drops file
→ POST /files/upload (temp) → { tempId, expiresAt }
→ store tempId in form state
→ user submits form
→ POST /correspondences (with tempFileIds) → backend commits in transaction
```
## Hook Pattern
```tsx
'use client';
import { useDropzone } from 'react-dropzone';
import { useMutation } from '@tanstack/react-query';
export function useTwoPhaseUpload() {
const uploadTemp = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append('file', file);
const { data } = await apiClient.post<{ tempId: string; expiresAt: string }>(
'/files/upload',
fd,
);
return data;
},
});
return uploadTemp;
}
```
## Form Integration (RHF)
```tsx
export function CorrespondenceForm() {
const form = useForm<FormData>({ resolver: zodResolver(schema) });
const uploadTemp = useTwoPhaseUpload();
const [tempFileIds, setTempFileIds] = useState<string[]>([]);
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
'image/vnd.dwg': ['.dwg'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/zip': ['.zip'],
},
maxSize: 50 * 1024 * 1024, // 50 MB — must match backend
onDrop: async (files) => {
const results = await Promise.all(files.map((f) => uploadTemp.mutateAsync(f)));
setTempFileIds((prev) => [...prev, ...results.map((r) => r.tempId)]);
},
});
const onSubmit = async (values: FormData) => {
await correspondenceService.create({
...values,
tempFileIds, // committed server-side in the same DB transaction
});
setTempFileIds([]);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<div {...getRootProps()} className="dropzone">
<input {...getInputProps()} />
<p>{t('upload.dragDrop')}</p>
</div>
{/* other fields */}
</form>
);
}
```
## Rules
- **Whitelist MIME types** — must mirror backend ADR-016 whitelist (`.pdf`, `.dwg`, `.docx`, `.xlsx`, `.zip`).
- **50 MB cap** — enforce client-side too (better UX) plus server-side (authoritative).
- **Show temp-file pills** with remove button — users see what will be attached.
- **Clear `tempFileIds` on success/cancel** — prevent stale IDs on subsequent submits.
- **No retry of expired temps** — if `expiresAt` passed, prompt re-upload.
## ❌ Forbidden
- ❌ Uploading directly to permanent storage endpoint (no commit phase)
- ❌ Hardcoded MIME list in component (keep in shared constant file mirrored from backend)
- ❌ Ignoring `maxSize` — backend will reject but UX suffers
## Reference
- [ADR-016 Security](../../../specs/06-Decision-Records/ADR-016-security-authentication.md)
- Backend rule: [`security-file-two-phase-upload.md`](../nestjs-best-practices/rules/security-file-two-phase-upload.md)
@@ -1,17 +1,19 @@
# UUID Handling (ADR-019)
# UUID Handling (ADR-019) — March 2026 Pattern
**Project-specific: Hybrid Identifier Strategy for NAP-DMS**
This project uses ADR-019: INT Primary Key (internal) + UUIDv7 (public API). Frontend code must handle this correctly.
> **Updated pattern:** Backend exposes `publicId` directly — ไม่มี `@Expose({ name: 'id' })` rename แล้ว. Frontend ใช้ `publicId` ตรงๆ — ห้าม fallback ไป `id`.
## The Pattern
| Source | Field Name | Type | Notes |
|--------|------------|------|-------|
| **API Response** | `id` | `string` (UUID) | Actually `publicId` exposed via `@Expose({ name: 'id' })` |
| **TypeScript Interface** | `publicId?: string` | UUID string | Use this for all references |
| **Fallback** | `id?: number` | INT (internal) | May be undefined due to `@Exclude()` |
| **Form Values** | `xxxUuid` | `string` | DTO field names: `projectUuid`, `contractUuid` |
| Source | Field Name | Type | Notes |
| ------------------------ | ------------------- | ----------------- | ----------------------------------------------------------- |
| **API Response** | `publicId` | `string` (UUIDv7) | Exposed directly (no rename) |
| **TypeScript Interface** | `publicId?: string` | UUID string | ใช้ตัวนี้เท่านั้น |
| **Form DTO** | `xxxUuid` | `string` | DTO field names: `projectUuid`, `contractUuid` (input only) |
| **URL param** | `[publicId]` | `string` (UUID) | e.g. `/correspondences/[publicId]/page.tsx` |
## Critical Rules
@@ -31,22 +33,26 @@ const id = +projectId; // NaN
apiClient.get(`/projects/${projectId}`); // projectId is already UUID string
```
### 2. Use `publicId ?? id` Pattern
### 2. Use `publicId` Only — NO `id ?? ''` Fallback
```tsx
// types/project.ts
// ✅ CORRECT — types/project.ts
interface Project {
id?: number; // Internal INT (may be undefined)
publicId?: string; // UUID from API (use this)
publicId?: string; // UUID from API — ใช้ตัวนี้เท่านั้น
projectCode: string;
projectName: string;
}
// Component usage
// ✅ CORRECT — Component usage
const projectOptions = projects.map((p) => ({
label: `${p.projectName} (${p.projectCode})`,
value: String(p.publicId ?? p.id ?? ''), // ADR-019 pattern
key: String(p.publicId ?? p.id ?? ''),
value: p.publicId ?? '', // ADR-019 — ไม่ต้อง String() และไม่ไป id
key: p.publicId ?? p.projectCode, // fallback ไป business field ได้
}));
// ❌ WRONG — pattern เก่า
const oldOptions = projects.map((p) => ({
value: String(p.publicId ?? p.id ?? ''), // ❌ `id ?? ''` fallback
}));
```
@@ -84,14 +90,13 @@ export function ContractSelect({ contracts, value, onChange }: ContractSelectPro
<SelectValue placeholder="เลือกสัญญา" />
</SelectTrigger>
<SelectContent>
{contracts.map((c) => (
<SelectItem
key={String(c.publicId ?? c.id ?? '')}
value={String(c.publicId ?? c.id ?? '')}
>
{c.contractName} ({c.contractCode})
</SelectItem>
))}
{contracts
.filter((c) => !!c.publicId) // กรอง contract ที่มี publicId เท่านั้น
.map((c) => (
<SelectItem key={c.publicId} value={c.publicId!}>
{c.contractName} ({c.contractCode})
</SelectItem>
))}
</SelectContent>
</Select>
);
@@ -113,7 +118,9 @@ const columns: ColumnDef<Discipline>[] = [
cell: ({ row }) => {
const contract = row.original.contract;
return contract ? (
<span>{contract.contractName} ({contract.contractCode})</span>
<span>
{contract.contractName} ({contract.contractCode})
</span>
) : (
<span className="text-muted-foreground">-</span>
);
@@ -153,10 +160,9 @@ export const contractService = {
## TypeScript Interfaces
```tsx
// types/entities.ts
// ✅ CORRECT — types/entities.ts
export interface BaseEntity {
id?: number; // Internal INT - may be undefined
publicId?: string; // UUID - use this for API calls
publicId?: string; // UUID — ใช้ตัวนี้เท่านั้น (ไม่มี INT id ใน interface)
createdAt?: string;
updatedAt?: string;
}
@@ -170,14 +176,12 @@ export interface Project extends BaseEntity {
export interface Contract extends BaseEntity {
contractCode: string;
contractName: string;
projectId?: number; // Internal INT FK
projectUuid?: string; // UUID for DTOs
project?: Project; // Relation
project?: Project; // Relation (nested entity)
}
// DTOs
// DTO (input only — รับ UUID จาก form)
export interface CreateContractDto {
projectUuid: string; // Accept UUID from form
projectUuid: string; // UUID string from select
contractCode: string;
contractName: string;
}
@@ -215,9 +219,7 @@ export function ContractForm() {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
<form onSubmit={form.handleSubmit(onSubmit)}>{/* Form fields */}</form>
</Form>
);
}
@@ -231,19 +233,20 @@ export default async function ContractPage({ params }: { params: Promise<{ id: s
const { id } = await params;
// id is UUID string from URL
const contract = await contractService.getById(id);
return <ContractDetail contract={contract} />;
}
```
## Common Pitfalls
| Pitfall | Wrong | Right |
|---------|-------|-------|
| Assuming `entity.id` exists | `key={entity.id}` | `key={entity.publicId ?? entity.id}` |
| parseInt on UUID | `parseInt(projectId)` | `projectId` (string) |
| Field name mismatch | `name="project_id"` | `name="projectUuid"` |
| Missing fallback | `value={entity.publicId}` | `value={entity.publicId ?? entity.id ?? ''}` |
| Pitfall | ❌ Wrong | ✅ Right |
| ---------------------------- | ------------------------------------------------ | --------------------------------- |
| Using INT `id` | `key={entity.id}` | `key={entity.publicId}` |
| parseInt on UUID | `parseInt(projectId)` | `projectId` (string) |
| Field name mismatch | `name="project_id"` | `name="projectUuid"` |
| `id ?? ''` fallback | `value={publicId ?? id ?? ''}` | `value={publicId ?? ''}` |
| `uuid` + `publicId` together | `interface { uuid?: string; publicId?: string }` | `interface { publicId?: string }` |
## Reference