Files
lcbp3/.agents/skills/next-best-practices/uuid-handling.md
T
admin 740c116b95
CI / CD Pipeline / build (push) Successful in 6m25s
CI / CD Pipeline / deploy (push) Failing after 39s
690326:2212 Fixing Refactor ADR-019 Naming convention uuid #08
2026-03-26 22:12:55 +07:00

6.9 KiB

UUID Handling (ADR-019)

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.

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

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 ?? id Pattern

// types/project.ts
interface Project {
  id?: number;           // Internal INT (may be undefined)
  publicId?: string;     // UUID from API (use this)
  projectCode: string;
  projectName: string;
}

// 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 ?? ''),
}));

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.map((c) => (
          <SelectItem 
            key={String(c.publicId ?? c.id ?? '')} 
            value={String(c.publicId ?? c.id ?? '')}
          >
            {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

// types/entities.ts
export interface BaseEntity {
  id?: number;           // Internal INT - may be undefined
  publicId?: string;     // UUID - use this for API calls
  createdAt?: string;
  updatedAt?: string;
}

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

export interface Contract extends BaseEntity {
  contractCode: string;
  contractName: string;
  projectId?: number;        // Internal INT FK
  projectUuid?: string;      // UUID for DTOs
  project?: Project;         // Relation
}

// DTOs
export interface CreateContractDto {
  projectUuid: string;       // Accept UUID from form
  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
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 ?? ''}

Reference

Warning

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