Files
lcbp3/.agents/skills/next-best-practices/uuid-handling.md
T
admin a57fef4d44
CI / CD Pipeline / build (push) Successful in 5m51s
CI / CD Pipeline / deploy (push) Successful in 2m9s
690427:0812 Update Infras #01
2026-04-27 08:12:28 +07:00

8.3 KiB

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 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

1. NEVER Use parseInt() on UUID

// ❌ WRONG - parseInt on UUID gives garbage
const id = parseInt(projectId); // "0195a1b2-..." → 195 (wrong!)

// ❌ WRONG - Number() on UUID
const id = Number(projectId); // NaN

// ❌ WRONG - Unary plus
const id = +projectId; // NaN

// ✅ CORRECT - Send UUID string directly to API
apiClient.get(`/projects/${projectId}`); // projectId is already UUID string

2. Use publicId Only — NO id ?? '' Fallback

// ✅ CORRECT — types/project.ts
interface Project {
  publicId?: string; // UUID from API — ใช้ตัวนี้เท่านั้น
  projectCode: string;
  projectName: string;
}

// ✅ CORRECT — Component usage
const projectOptions = projects.map((p) => ({
  label: `${p.projectName} (${p.projectCode})`,
  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
}));

3. Form Field Names (camelCase)

// ❌ WRONG - snake_case doesn't match TypeScript interface
fields={[{ name: 'project_id', label: 'Project' }]}

// ✅ CORRECT - camelCase matches interface
fields={[{ name: 'projectUuid', label: 'Project' }]}

// Form submission
const onSubmit = (data: { projectUuid: string }) => {
  // projectUuid is UUID string - send as-is
  await apiClient.post('/contracts', data);
};

Select Component Pattern

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';

interface ContractSelectProps {
  contracts: Contract[];
  value: string;
  onChange: (value: string) => void;
}

export function ContractSelect({ contracts, value, onChange }: ContractSelectProps) {
  return (
    <Select value={value} onValueChange={onChange}>
      <SelectTrigger>
        <SelectValue placeholder="เลือกสัญญา" />
      </SelectTrigger>
      <SelectContent>
        {contracts
          .filter((c) => !!c.publicId) // กรอง contract ที่มี publicId เท่านั้น
          .map((c) => (
            <SelectItem key={c.publicId} value={c.publicId!}>
              {c.contractName} ({c.contractCode})
            </SelectItem>
          ))}
      </SelectContent>
    </Select>
  );
}

Data Table Pattern

// Show relation columns with UUID entities
const columns: ColumnDef<Discipline>[] = [
  {
    accessorKey: 'disciplineCode',
    header: 'Code',
  },
  {
    accessorKey: 'contract',
    header: 'Contract',
    cell: ({ row }) => {
      const contract = row.original.contract;
      return contract ? (
        <span>
          {contract.contractName} ({contract.contractCode})
        </span>
      ) : (
        <span className="text-muted-foreground">-</span>
      );
    },
  },
];

API Service Pattern

// lib/services/contract.service.ts
export const contractService = {
  async getById(uuid: string): Promise<Contract> {
    // Send UUID string directly - backend resolves to INT
    const { data } = await apiClient.get(`/contracts/${uuid}`);
    return data;
  },

  async create(dto: CreateContractDto): Promise<Contract> {
    // DTO contains projectUuid (UUID string)
    const { data } = await apiClient.post('/contracts', dto);
    return data;
  },

  async update(uuid: string, dto: Partial<CreateContractDto>): Promise<Contract> {
    const { data } = await apiClient.put(`/contracts/${uuid}`, dto);
    return data;
  },

  async delete(uuid: string): Promise<void> {
    await apiClient.delete(`/contracts/${uuid}`);
  },
};

TypeScript Interfaces

// ✅ CORRECT — types/entities.ts
export interface BaseEntity {
  publicId?: string; // UUID — ใช้ตัวนี้เท่านั้น (ไม่มี INT id ใน interface)
  createdAt?: string;
  updatedAt?: string;
}

export interface Project extends BaseEntity {
  projectCode: string;
  projectName: string;
  description?: string;
}

export interface Contract extends BaseEntity {
  contractCode: string;
  contractName: string;
  project?: Project; // Relation (nested entity)
}

// DTO (input only — รับ UUID จาก form)
export interface CreateContractDto {
  projectUuid: string; // UUID string from select
  contractCode: string;
  contractName: string;
}

Form with React Hook Form + Zod

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const formSchema = z.object({
  projectUuid: z.string().uuid('กรุณาเลือกโปรเจกต์'),
  contractCode: z.string().min(1, 'กรุณาระบุรหัสสัญญา'),
  contractName: z.string().min(1, 'กรุณาระบุชื่อสัญญา'),
});

type FormData = z.infer<typeof formSchema>;

export function ContractForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      projectUuid: '',
      contractCode: '',
      contractName: '',
    },
  });

  const onSubmit = async (data: FormData) => {
    // Send UUID strings directly
    await contractService.create(data);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>{/* Form fields */}</form>
    </Form>
  );
}

URL Parameters

// app/contracts/[id]/page.tsx
export default async function ContractPage({ params }: { params: Promise<{ id: string }> }) {
  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
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

Warning

: Using parseInt() on UUID values causes data corruption. Always use UUID strings directly in API calls.