690427:0812 Update Infras #01
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user