Compare commits

...

4 Commits

Author SHA1 Message Date
admin a723cae244 690320:2126 UUID agian by Claude Sonnet #02
Build and Deploy / deploy (push) Failing after 4m27s
2026-03-20 21:26:23 +07:00
admin 90cbbb8f11 690320:2053 UUID agian #01
Build and Deploy / deploy (push) Failing after 2m38s
2026-03-20 20:53:50 +07:00
admin e3859c8349 690320:2026 login #02
Build and Deploy / deploy (push) Successful in 6m50s
2026-03-20 20:26:03 +07:00
admin bac263c097 690320:2012 login #01
Build and Deploy / deploy (push) Successful in 6m43s
2026-03-20 20:12:03 +07:00
22 changed files with 287 additions and 163 deletions
+5 -1
View File
@@ -85,6 +85,7 @@ export class UserService {
.leftJoinAndSelect('user.preference', 'preference') // Optional .leftJoinAndSelect('user.preference', 'preference') // Optional
.leftJoinAndSelect('user.assignments', 'assignments') .leftJoinAndSelect('user.assignments', 'assignments')
.leftJoinAndSelect('assignments.role', 'role') .leftJoinAndSelect('assignments.role', 'role')
.leftJoin('user.organization', 'organization') // ADR-019: expose uuid, not INT
.select([ .select([
'user.user_id', 'user.user_id',
'user.uuid', 'user.uuid',
@@ -93,13 +94,15 @@ export class UserService {
'user.firstName', 'user.firstName',
'user.lastName', 'user.lastName',
'user.lineId', 'user.lineId',
'user.primaryOrganizationId',
'user.isActive', 'user.isActive',
'user.createdAt', 'user.createdAt',
'user.updatedAt', 'user.updatedAt',
'assignments.id', 'assignments.id',
'role.roleId', 'role.roleId',
'role.roleName', 'role.roleName',
'organization.uuid',
'organization.organizationCode',
'organization.organizationName',
]); ]);
// Apply Filters // Apply Filters
@@ -165,6 +168,7 @@ export class UserService {
'assignments', 'assignments',
'assignments.role', 'assignments.role',
'assignments.role.permissions', 'assignments.role.permissions',
'organization', // ADR-019: expose org.uuid, not INT primaryOrganizationId
], ],
}); });
@@ -92,11 +92,7 @@ export default function UsersPage() {
{ {
id: "organization", id: "organization",
header: "Organization", header: "Organization",
cell: ({ row }) => { cell: ({ row }) => row.original.organization?.organizationCode ?? "-",
const orgId = row.original.primaryOrganizationId;
const org = (organizations as Organization[]).find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString());
return org ? org.organizationCode : "-";
},
}, },
{ {
id: "roles", id: "roles",
@@ -50,7 +50,8 @@ import { SearchContractDto, CreateContractDto, UpdateContractDto } from "@/types
import { AxiosError } from "axios"; import { AxiosError } from "axios";
interface Project { interface Project {
id: string; // ADR-019: uuid exposed as 'id' uuid: string;
id?: number;
projectCode: string; projectCode: string;
projectName: string; projectName: string;
} }
@@ -82,14 +83,14 @@ const contractSchema = z.object({
type ContractFormData = z.infer<typeof contractSchema>; type ContractFormData = z.infer<typeof contractSchema>;
const useContracts = (params?: SearchContractDto) => { const useContracts = (params?: SearchContractDto) => {
return useQuery({ return useQuery<Contract[]>({
queryKey: ['contracts', params], queryKey: ['contracts', params],
queryFn: () => contractService.getAll(params), queryFn: () => contractService.getAll(params),
}); });
}; };
const useProjectsList = () => { const useProjectsList = () => {
return useQuery({ return useQuery<Project[]>({
queryKey: ['projects-list'], queryKey: ['projects-list'],
queryFn: () => projectService.getAll(), queryFn: () => projectService.getAll(),
}); });
+1 -1
View File
@@ -107,7 +107,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
lastName: user.lastName, lastName: user.lastName,
isActive: user.isActive, isActive: user.isActive,
lineId: user.lineId || "", lineId: user.lineId || "",
primaryOrganizationId: user.primaryOrganizationId?.toString(), primaryOrganizationId: user.organization?.uuid ?? undefined,
roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [], roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [],
password: "", password: "",
confirmPassword: "" confirmPassword: ""
+14 -16
View File
@@ -18,9 +18,10 @@ import { FileUploadZone } from "@/components/custom/file-upload-zone";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence"; import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence";
import { Organization } from "@/types/organization"; import { Organization, Correspondence, CorrespondenceRevision } from "@/types/correspondence";
import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from "@/hooks/use-master-data"; import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from "@/hooks/use-master-data";
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto"; import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
import type { ProjectListItem } from "@/lib/services/project.service";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service"; import { correspondenceService } from "@/lib/services/correspondence.service";
@@ -42,7 +43,7 @@ const correspondenceSchema = z.object({
type FormData = z.infer<typeof correspondenceSchema>; type FormData = z.infer<typeof correspondenceSchema>;
export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, uuid?: string }) { export function CorrespondenceForm({ initialData, uuid }: { initialData?: Correspondence, uuid?: string }) {
const router = useRouter(); const router = useRouter();
const createMutation = useCreateCorrespondence(); const createMutation = useCreateCorrespondence();
const updateMutation = useUpdateCorrespondence(); const updateMutation = useUpdateCorrespondence();
@@ -53,23 +54,20 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
const { data: correspondenceTypes, isLoading: isLoadingTypes } = useCorrespondenceTypes(); const { data: correspondenceTypes, isLoading: isLoadingTypes } = useCorrespondenceTypes();
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(); const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines();
// Extract initial values if editing // Extract initial values if editing — ADR-019: use nested relation UUIDs, never raw INT FK columns
const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0]; const currentRev = initialData?.revisions?.find((r: CorrespondenceRevision) => r.isCurrent) ?? initialData?.revisions?.[0];
const defaultValues: Partial<FormData> = { const defaultValues: Partial<FormData> = {
projectId: initialData?.projectId ? String(initialData.projectId) : undefined, projectId: initialData?.project?.uuid ?? undefined,
documentTypeId: initialData?.correspondenceTypeId || undefined, documentTypeId: initialData?.correspondenceTypeId || undefined,
disciplineId: initialData?.disciplineId || undefined, disciplineId: initialData?.disciplineId || undefined,
subject: currentRev?.subject || currentRev?.title || "", subject: currentRev?.subject || "",
description: currentRev?.description || "", description: currentRev?.description || "",
body: currentRev?.body || "", body: currentRev?.body || "",
remarks: currentRev?.remarks || "", remarks: currentRev?.remarks || "",
dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined, dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined,
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined, fromOrganizationId: initialData?.originator?.uuid ?? undefined,
// Map initial recipient (TO) - Simplified for now toOrganizationId: initialData?.recipients?.find(r => r.recipientType === 'TO')?.recipientOrganization?.uuid ?? undefined,
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId importance: (currentRev?.details?.['importance'] as "NORMAL" | "HIGH" | "URGENT") ?? "NORMAL",
? String(initialData.recipients.find((r: any) => r.recipientType === 'TO').recipientOrganizationId)
: undefined,
importance: currentRev?.details?.importance || "NORMAL",
}; };
const { const {
@@ -219,8 +217,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} /> <SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(projects || []).map((p: any) => ( {(projects || []).map((p: ProjectListItem) => (
<SelectItem key={p.id} value={String(p.id)}> <SelectItem key={p.uuid} value={p.uuid}>
{p.projectName} ({p.projectCode}) {p.projectName} ({p.projectCode})
</SelectItem> </SelectItem>
))} ))}
@@ -243,7 +241,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} /> <SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(correspondenceTypes || []).map((t: any) => ( {(correspondenceTypes || []).map((t: { id: number; typeName: string; typeCode: string }) => (
<SelectItem key={t.id} value={String(t.id)}> <SelectItem key={t.id} value={String(t.id)}>
{t.typeName} ({t.typeCode}) {t.typeName} ({t.typeCode})
</SelectItem> </SelectItem>
@@ -267,7 +265,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} /> <SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(disciplines || []).map((d: any) => ( {(disciplines || []).map((d: { id: number; disciplineCode?: string; codeNameEn?: string }) => (
<SelectItem key={d.id} value={String(d.id)}> <SelectItem key={d.id} value={String(d.id)}>
{d.codeNameEn || d.disciplineCode} {d.codeNameEn || d.disciplineCode}
</SelectItem> </SelectItem>
+4 -3
View File
@@ -6,6 +6,7 @@ import {
UpdateOrganizationDto, UpdateOrganizationDto,
SearchOrganizationDto, SearchOrganizationDto,
} from '@/types/dto/organization/organization.dto'; } from '@/types/dto/organization/organization.dto';
import { Organization } from '@/types/organization';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { organizationService } from '@/lib/services/organization.service'; import { organizationService } from '@/lib/services/organization.service';
import { projectService } from '@/lib/services/project.service'; import { projectService } from '@/lib/services/project.service';
@@ -19,7 +20,7 @@ export const masterDataKeys = {
}; };
export function useOrganizations(params?: SearchOrganizationDto) { export function useOrganizations(params?: SearchOrganizationDto) {
return useQuery({ return useQuery<Organization[]>({
queryKey: [...masterDataKeys.organizations(), params], queryKey: [...masterDataKeys.organizations(), params],
queryFn: () => organizationService.getAll(params), queryFn: () => organizationService.getAll(params),
}); });
@@ -112,7 +113,7 @@ export function useContractDrawingCategories(projectId?: number | string) {
}); });
} }
export function useShopMainCategories(projectId: number) { export function useShopMainCategories(projectId: number | string) {
return useQuery({ return useQuery({
queryKey: ['shop-main-categories', projectId], queryKey: ['shop-main-categories', projectId],
queryFn: () => masterDataService.getShopMainCategories(projectId), queryFn: () => masterDataService.getShopMainCategories(projectId),
@@ -120,7 +121,7 @@ export function useShopMainCategories(projectId: number) {
}); });
} }
export function useShopSubCategories(projectId: number, mainCategoryId?: number) { export function useShopSubCategories(projectId: number | string, mainCategoryId?: number) {
return useQuery({ return useQuery({
queryKey: ['shop-sub-categories', projectId, mainCategoryId], queryKey: ['shop-sub-categories', projectId, mainCategoryId],
queryFn: () => masterDataService.getShopSubCategories(projectId, mainCategoryId), queryFn: () => masterDataService.getShopSubCategories(projectId, mainCategoryId),
+2 -1
View File
@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectService } from '@/lib/services/project.service'; import { projectService } from '@/lib/services/project.service';
import type { ProjectListItem } from '@/lib/services/project.service';
import { CreateProjectDto, UpdateProjectDto, SearchProjectDto } from '@/types/dto/project/project.dto'; import { CreateProjectDto, UpdateProjectDto, SearchProjectDto } from '@/types/dto/project/project.dto';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getApiErrorMessage } from '@/types/api-error'; import { getApiErrorMessage } from '@/types/api-error';
@@ -11,7 +12,7 @@ export const projectKeys = {
}; };
export function useProjects(params?: SearchProjectDto) { export function useProjects(params?: SearchProjectDto) {
return useQuery({ return useQuery<ProjectListItem[]>({
queryKey: projectKeys.list(params || {}), queryKey: projectKeys.list(params || {}),
queryFn: () => projectService.getAll(params), queryFn: () => projectService.getAll(params),
}); });
+98 -39
View File
@@ -5,24 +5,65 @@ import { z } from "zod";
import type { User } from "next-auth"; import type { User } from "next-auth";
import type { JWT } from "next-auth/jwt"; import type { JWT } from "next-auth/jwt";
type ApiEnvelope<T> = {
data?: ApiEnvelope<T> | T;
message?: string;
statusCode?: number;
};
const authResponseSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1).optional(),
user: z
.object({
user_id: z.number(),
username: z.string().min(1),
email: z.string(),
firstName: z.string().min(1),
lastName: z.string().min(1),
role: z.string().min(1).optional(),
primaryOrganizationId: z.number().nullable().optional(),
})
.optional(),
});
// Schema for input validation // Schema for input validation
const loginSchema = z.object({ const loginSchema = z.object({
username: z.string().min(1), username: z.string().min(1),
password: z.string().min(1), password: z.string().min(1),
}); });
const baseUrl = (typeof window === "undefined" ? process.env.INTERNAL_API_URL : null) || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; // ✅ ใช้แบบ SSR-safe (ดีที่สุด)
const baseUrl =
(typeof window === "undefined" ? process.env.INTERNAL_API_URL : null) ||
process.env.NEXT_PUBLIC_API_URL ||
"http://localhost:3001/api";
// Helper to parse JWT expiry // Helper to parse JWT expiry
function getJwtExpiry(token: string): number { function getJwtExpiry(token: string): number {
try { try {
const payload = JSON.parse(atob(token.split('.')[1])); const payload = JSON.parse(atob(token.split(".")[1]));
return payload.exp * 1000; // Convert to ms return payload.exp * 1000;
} catch { } catch {
return Date.now(); // If invalid, treat as expired return Date.now();
} }
} }
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null {
let current: ApiEnvelope<T> | T | null = payload;
while (
current &&
typeof current === "object" &&
"data" in current &&
current.data !== undefined
) {
current = current.data;
}
return current;
}
async function refreshAccessToken(token: JWT) { async function refreshAccessToken(token: JWT) {
try { try {
const response = await fetch(`${baseUrl}/auth/refresh`, { const response = await fetch(`${baseUrl}/auth/refresh`, {
@@ -38,17 +79,22 @@ async function refreshAccessToken(token: JWT) {
throw refreshedTokens; throw refreshedTokens;
} }
const data = refreshedTokens.data || refreshedTokens; const parsedAuthResponse = authResponseSchema.safeParse(
unwrapApiData(refreshedTokens)
);
if (!parsedAuthResponse.success) {
throw new Error("Invalid refresh token response");
}
return { return {
...token, ...token,
accessToken: data.access_token, accessToken: parsedAuthResponse.data.access_token,
accessTokenExpires: getJwtExpiry(data.access_token), accessTokenExpires: getJwtExpiry(parsedAuthResponse.data.access_token),
refreshToken: data.refresh_token ?? token.refreshToken, refreshToken:
parsedAuthResponse.data.refresh_token ?? token.refreshToken,
}; };
} catch (error) { } catch {
// RefreshAccessTokenError - token will be invalidated
return { return {
...token, ...token,
error: "RefreshAccessTokenError", error: "RefreshAccessTokenError",
@@ -73,15 +119,14 @@ export const {
if (!credentials?.username || !credentials?.password) return null; if (!credentials?.username || !credentials?.password) return null;
try { try {
// 1. Sanitize payload (Only send username and password) const payload = loginSchema.parse({
const payload = { username: credentials.username,
username: credentials.username as string, password: credentials.password,
password: credentials.password as string, });
};
console.log(`[AUTH] Attempting login at: ${baseUrl}/auth/login`); console.log(`[AUTH] Attempting login at: ${baseUrl}/auth/login`);
console.log(`[AUTH] Current process.env.INTERNAL_API_URL: ${process.env.INTERNAL_API_URL}`); console.log(`[AUTH] INTERNAL_API_URL: ${process.env.INTERNAL_API_URL}`);
console.log(`[AUTH] Current process.env.NEXT_PUBLIC_API_URL: ${process.env.NEXT_PUBLIC_API_URL}`); console.log(`[AUTH] NEXT_PUBLIC_API_URL: ${process.env.NEXT_PUBLIC_API_URL}`);
const res = await fetch(`${baseUrl}/auth/login`, { const res = await fetch(`${baseUrl}/auth/login`, {
method: "POST", method: "POST",
@@ -89,7 +134,7 @@ export const {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
cache: 'no-store', // Disable caching for auth requests cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {
@@ -99,30 +144,47 @@ export const {
return null; return null;
} }
const data = await res.json(); const responseJson = await res.json();
// Handling both { data: { ... } } and direct { ... } response formats console.log("[AUTH] Backend raw response:", JSON.stringify(responseJson));
const backendData = data.data || data;
if (!backendData || !backendData.access_token) { const parsedAuthResponse = authResponseSchema.safeParse(
console.error("[AUTH] Login failed: Invalid response format from backend (missing access_token)"); unwrapApiData(responseJson)
);
if (!parsedAuthResponse.success || !parsedAuthResponse.data.user) {
console.error(
"[AUTH] Invalid backend response:",
unwrapApiData(responseJson)
);
return null; return null;
} }
console.log(`[AUTH] Login Successful for user: ${backendData.user?.username || 'unknown'}`); const backendData = parsedAuthResponse.data;
const user = backendData.user;
if (!user) {
console.error("[AUTH] Invalid backend response:", backendData);
return null;
}
console.log(
`[AUTH] Login Successful for user: ${
user.username || "unknown"
}`
);
return { return {
id: backendData.user.user_id.toString(), id: user.user_id.toString(),
name: `${backendData.user.firstName} ${backendData.user.lastName}`, name: `${user.firstName} ${user.lastName}`,
email: backendData.user.email, email: user.email,
username: backendData.user.username, username: user.username,
role: backendData.user.role || "User", role: user.role || "User",
organizationId: backendData.user.primaryOrganizationId, organizationId: user.primaryOrganizationId,
accessToken: backendData.access_token, accessToken: backendData.access_token,
refreshToken: backendData.refresh_token, refreshToken: backendData.refresh_token,
} as User; } as User;
} catch (error) { } catch (error) {
console.error("[AUTH] Network/Fetch Error during authorize:", error); console.error("[AUTH] Network Error:", error);
return null; return null;
} }
}, },
@@ -138,7 +200,7 @@ export const {
return { return {
...token, ...token,
id: user.id, id: user.id,
username: user.username, // ✅ Save username username: user.username,
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
accessToken: user.accessToken, accessToken: user.accessToken,
@@ -147,23 +209,20 @@ export const {
}; };
} }
// Return previous token if valid (minus 10s buffer)
if (Date.now() < (token.accessTokenExpires as number) - 10000) { if (Date.now() < (token.accessTokenExpires as number) - 10000) {
return token; return token;
} }
// If existing token has an error, do not retry refresh (prevents infinite loop)
if (token.error) { if (token.error) {
return token; return token;
} }
// Token expired, refresh it
return refreshAccessToken(token); return refreshAccessToken(token);
}, },
async session({ session, token }) { async session({ session, token }) {
if (token && session.user) { if (token && session.user) {
session.user.id = token.id as string; session.user.id = token.id as string;
session.user.username = token.username as string; // ✅ Restore username session.user.username = token.username as string;
session.user.role = token.role as string; session.user.role = token.role as string;
session.user.organizationId = token.organizationId as number; session.user.organizationId = token.organizationId as number;
@@ -176,7 +235,7 @@ export const {
}, },
session: { session: {
strategy: "jwt", strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours maxAge: 24 * 60 * 60,
}, },
secret: process.env.AUTH_SECRET, secret: process.env.AUTH_SECRET,
debug: process.env.NODE_ENV === "development", debug: process.env.NODE_ENV === "development",
+40 -35
View File
@@ -15,14 +15,39 @@ import {
SearchOrganizationDto, SearchOrganizationDto,
} from "@/types/dto/organization/organization.dto"; } from "@/types/dto/organization/organization.dto";
type ApiEnvelope<T> = {
data?: ApiEnvelope<T> | T;
message?: string;
statusCode?: number;
};
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null {
let current: ApiEnvelope<T> | T | null = payload;
while (
current &&
typeof current === "object" &&
"data" in current &&
current.data !== undefined
) {
current = current.data;
}
return current;
}
function unwrapArrayResponse<T>(payload: ApiEnvelope<T[]> | T[]): T[] {
const unwrapped = unwrapApiData(payload);
return Array.isArray(unwrapped) ? unwrapped : [];
}
export const masterDataService = { export const masterDataService = {
// --- Tags Management --- // --- Tags Management ---
/** ดึงรายการ Tags ทั้งหมด (Search & Pagination) */ /** ดึงรายการ Tags ทั้งหมด (Search & Pagination) */
getTags: async (params?: SearchTagDto) => { getTags: async (params?: SearchTagDto) => {
const response = await apiClient.get("/master/tags", { params }); const response = await apiClient.get("/master/tags", { params });
// Support both wrapped and unwrapped scenarios return unwrapArrayResponse(response.data);
return response.data.data || response.data;
}, },
/** สร้าง Tag ใหม่ */ /** สร้าง Tag ใหม่ */
@@ -48,27 +73,7 @@ export const masterDataService = {
/** ดึงรายชื่อองค์กรทั้งหมด */ /** ดึงรายชื่อองค์กรทั้งหมด */
getOrganizations: async (params?: SearchOrganizationDto) => { getOrganizations: async (params?: SearchOrganizationDto) => {
const response = await apiClient.get<Organization[] | { data: Organization[] }>("/organizations", { params }); const response = await apiClient.get<Organization[] | { data: Organization[] }>("/organizations", { params });
// Support paginated response return unwrapArrayResponse(response.data as ApiEnvelope<Organization[]> | Organization[]);
if (response.data && Array.isArray((response.data as { data: Organization[] }).data)) {
return (response.data as { data: Organization[] }).data;
}
// If response.data itself is an array
if (Array.isArray(response.data)) {
return response.data;
}
// If we're here, it might be { data: [], total: ... } but data is missing? or empty?
// Or it returned the object but data.data check failed (shouldn't happen if it follows schema).
// Let's default to [] if we can't find an array, because callers expect array.
// However, if we return [] we lose data if it was there but not recognized.
// Fallback: Check if response.data is object?
// If it's the paginated object, return the data array if it exists
if (response.data && (response.data as { data: Organization[] }).data) {
// Maybe it's not an array?
return Array.isArray((response.data as { data: Organization[] }).data) ? (response.data as { data: Organization[] }).data : [];
}
return []; // Return empty array to prevent map errors
}, },
/** สร้างองค์กรใหม่ */ /** สร้างองค์กรใหม่ */
@@ -97,7 +102,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/disciplines", { const response = await apiClient.get("/master/disciplines", {
params: { contractId } params: { contractId }
}); });
return response.data.data || response.data; return unwrapArrayResponse(response.data);
}, },
/** สร้างสาขางานใหม่ */ /** สร้างสาขางานใหม่ */
@@ -119,7 +124,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/sub-types", { const response = await apiClient.get("/master/sub-types", {
params: { contractId, correspondenceTypeId: typeId } params: { contractId, correspondenceTypeId: typeId }
}); });
return response.data.data || response.data; return unwrapArrayResponse(response.data);
}, },
/** สร้างประเภทย่อยใหม่ */ /** สร้างประเภทย่อยใหม่ */
@@ -135,7 +140,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/rfa-types", { const response = await apiClient.get("/master/rfa-types", {
params: { contractId } params: { contractId }
}); });
return response.data.data || response.data; return unwrapArrayResponse(response.data);
}, },
/** สร้างประเภท RFA ใหม่ */ /** สร้างประเภท RFA ใหม่ */
@@ -156,7 +161,7 @@ export const masterDataService = {
// --- Correspondence Types Management --- // --- Correspondence Types Management ---
getCorrespondenceTypes: async () => { getCorrespondenceTypes: async () => {
const response = await apiClient.get("/master/correspondence-types"); const response = await apiClient.get("/master/correspondence-types");
return response.data.data || response.data; return unwrapArrayResponse(response.data);
}, },
createCorrespondenceType: async (data: CreateCorrespondenceTypeDto) => { createCorrespondenceType: async (data: CreateCorrespondenceTypeDto) => {
@@ -188,21 +193,21 @@ export const masterDataService = {
// --- Drawing Categories --- // --- Drawing Categories ---
getContractDrawingCategories: async (projectId?: number | string) => { getContractDrawingCategories: async (projectId?: number | string) => {
const response = await apiClient.get("/drawings/contract/categories", { const response = await apiClient.get("/drawings/master-data/contract/categories", {
params: { projectId } params: { projectId }
}); });
return response.data.data || response.data; return unwrapArrayResponse(response.data);
}, },
getShopMainCategories: async (projectId: number) => { getShopMainCategories: async (projectId: number | string) => {
const response = await apiClient.get("/drawings/shop/main-categories", { params: { projectId } }); const response = await apiClient.get("/drawings/master-data/shop/main-categories", { params: { projectId } });
return response.data.data || response.data; return unwrapArrayResponse(response.data);
}, },
getShopSubCategories: async (projectId: number, mainCategoryId?: number) => { getShopSubCategories: async (projectId: number | string, mainCategoryId?: number) => {
const response = await apiClient.get("/drawings/shop/sub-categories", { const response = await apiClient.get("/drawings/master-data/shop/sub-categories", {
params: { projectId, mainCategoryId } params: { projectId, mainCategoryId }
}); });
return response.data.data || response.data; return unwrapArrayResponse(response.data);
} }
}; };
+33 -7
View File
@@ -4,19 +4,45 @@ import {
UpdateOrganizationDto, UpdateOrganizationDto,
SearchOrganizationDto, SearchOrganizationDto,
} from "@/types/dto/organization/organization.dto"; } from "@/types/dto/organization/organization.dto";
import { Organization } from "@/types/organization";
type ApiEnvelope<T> = {
data?: ApiEnvelope<T> | T;
message?: string;
statusCode?: number;
};
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null {
let current: ApiEnvelope<T> | T | null = payload;
while (
current &&
typeof current === "object" &&
"data" in current &&
current.data !== undefined
) {
current = current.data;
}
return current;
}
function unwrapArrayResponse<T>(payload: ApiEnvelope<T[]> | T[]): T[] {
const unwrapped = unwrapApiData(payload);
return Array.isArray(unwrapped) ? unwrapped : [];
}
export const organizationService = { export const organizationService = {
/** /**
* Get all organizations (supports filtering by projectId) * Get all organizations (supports filtering by projectId)
* GET /organizations?projectId=1 * GET /organizations?projectId=1
*/ */
getAll: async (params?: SearchOrganizationDto) => { getAll: async (params?: SearchOrganizationDto): Promise<Organization[]> => {
const response = await apiClient.get("/organizations", { params }); const response = await apiClient.get<ApiEnvelope<Organization[]> | Organization[]>(
// Normalize response if wrapped in data.data or direct data "/organizations",
if (response.data && Array.isArray(response.data.data)) { { params }
return response.data.data; );
} return unwrapArrayResponse(response.data);
return response.data.data || response.data;
}, },
/** /**
+41 -7
View File
@@ -6,6 +6,42 @@ import {
SearchProjectDto SearchProjectDto
} from "@/types/dto/project/project.dto"; } from "@/types/dto/project/project.dto";
export interface ProjectListItem {
uuid: string;
id?: number;
projectCode: string;
projectName: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
type ApiEnvelope<T> = {
data?: ApiEnvelope<T> | T;
message?: string;
statusCode?: number;
};
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null {
let current: ApiEnvelope<T> | T | null = payload;
while (
current &&
typeof current === "object" &&
"data" in current &&
current.data !== undefined
) {
current = current.data;
}
return current;
}
function unwrapArrayResponse<T>(payload: ApiEnvelope<T[]> | T[]): T[] {
const unwrapped = unwrapApiData(payload);
return Array.isArray(unwrapped) ? unwrapped : [];
}
export const projectService = { export const projectService = {
// --- Basic CRUD --- // --- Basic CRUD ---
@@ -13,14 +49,12 @@ export const projectService = {
* ( Search & Pagination) * ( Search & Pagination)
* ( getAllProjects params ) * ( getAllProjects params )
*/ */
getAll: async (params?: SearchProjectDto) => { getAll: async (params?: SearchProjectDto): Promise<ProjectListItem[]> => {
// GET /projects // GET /projects
const response = await apiClient.get("/projects", { params }); const response = await apiClient.get<
// Handle paginated response ApiEnvelope<ProjectListItem[]> | ProjectListItem[]
if (response.data && Array.isArray(response.data.data)) { >("/projects", { params });
return response.data.data; return unwrapArrayResponse(response.data);
}
return response.data;
}, },
/** ดึงรายละเอียดโครงการตาม UUID */ /** ดึงรายละเอียดโครงการตาม UUID */
+3 -3
View File
@@ -89,10 +89,10 @@ export interface CirculationListResponse {
* DTO for creating a circulation * DTO for creating a circulation
*/ */
export interface CreateCirculationDto { export interface CreateCirculationDto {
correspondenceId: number | string; correspondenceId: string; // ADR-019: UUID string only
projectId?: number | string; projectId?: string; // ADR-019: UUID string only
subject: string; subject: string;
assigneeIds: (number | string)[]; assigneeIds: string[]; // ADR-019: UUID string only
remarks?: string; remarks?: string;
} }
+3 -3
View File
@@ -76,7 +76,7 @@ export interface Correspondence {
} }
export interface CreateCorrespondenceDto { export interface CreateCorrespondenceDto {
projectId: number; projectId: string; // ADR-019: UUID string only
typeId: number; typeId: number;
subTypeId?: number; subTypeId?: number;
disciplineId?: number; disciplineId?: number;
@@ -87,7 +87,7 @@ export interface CreateCorrespondenceDto {
description?: string; description?: string;
details?: Record<string, unknown>; details?: Record<string, unknown>;
isInternal?: boolean; isInternal?: boolean;
originatorId?: number; originatorId?: string; // ADR-019: UUID string only
recipients?: { organizationId: number; type: 'TO' | 'CC' }[]; recipients?: { organizationId: string; type: 'TO' | 'CC' }[]; // ADR-019: UUID string only
attachments?: File[]; attachments?: File[];
} }
@@ -1,14 +1,14 @@
// File: src/types/dto/circulation/create-circulation.dto.ts // File: src/types/dto/circulation/create-circulation.dto.ts
export interface CreateCirculationDto { export interface CreateCirculationDto {
/** เอกสารต้นเรื่องที่จะเวียน (Correspondence ID or UUID) */ /** UUID ของเอกสารต้นเรื่องที่จะเวียน (ADR-019: UUID string only) */
correspondenceId: number | string; correspondenceId: string;
/** หัวข้อเรื่อง (Subject) */ /** หัวข้อเรื่อง (Subject) */
subject: string; subject: string;
/** รายชื่อ User ID/UUID ที่ต้องการส่งให้ (ผู้รับผิดชอบ) */ /** UUID ของ User ที่ต้องการส่งให้ (ADR-019: UUID string only) */
assigneeIds: (number | string)[]; assigneeIds: string[];
/** หมายเหตุเพิ่มเติม (ถ้ามี) */ /** หมายเหตุเพิ่มเติม (ถ้ามี) */
remarks?: string; remarks?: string;
+2 -2
View File
@@ -1,7 +1,7 @@
export interface CreateContractDto { export interface CreateContractDto {
contractCode: string; contractCode: string;
contractName: string; contractName: string;
projectId: number | string; projectId: string; // ADR-019: UUID string only
description?: string; description?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
@@ -11,7 +11,7 @@ export type UpdateContractDto = Partial<CreateContractDto>;
export interface SearchContractDto { export interface SearchContractDto {
search?: string; search?: string;
projectId?: number | string; projectId?: string; // ADR-019: UUID string only
page?: number; page?: number;
limit?: number; limit?: number;
} }
@@ -1,8 +1,8 @@
// File: src/types/dto/correspondence/create-correspondence.dto.ts // File: src/types/dto/correspondence/create-correspondence.dto.ts
export interface CreateCorrespondenceDto { export interface CreateCorrespondenceDto {
/** ID or UUID ของโครงการ */ /** UUID ของโครงการ (ADR-019: UUID string only) */
projectId: number | string; projectId: string;
/** ID ของประเภทเอกสาร (เช่น RFA, LETTER) */ /** ID ของประเภทเอกสาร (เช่น RFA, LETTER) */
typeId: number; typeId: number;
@@ -34,11 +34,11 @@ export interface CreateCorrespondenceDto {
/** เอกสารภายในหรือไม่ (True = ภายใน) */ /** เอกสารภายในหรือไม่ (True = ภายใน) */
isInternal?: boolean; isInternal?: boolean;
/** * Field Impersonation () /** Field Impersonation ()
* Admin * Admin (ADR-019: UUID string only)
*/ */
originatorId?: number | string; originatorId?: string;
/** รายชื่อผู้รับ */ /** รายชื่อผู้รับ (ADR-019: UUID string only) */
recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[]; recipients?: { organizationId: string; type: 'TO' | 'CC' }[];
} }
+6 -6
View File
@@ -3,8 +3,8 @@ import type { RFAItem } from '@/types/rfa';
// --- Create --- // --- Create ---
export interface CreateRfaDto { export interface CreateRfaDto {
/** ID or UUID ของโครงการ */ /** UUID ของโครงการ (ADR-019: UUID string only) */
projectId: number | string; // ADR-019: Accept UUID projectId: string;
/** ประเภท RFA (เช่น DWG, MAT) */ /** ประเภท RFA (เช่น DWG, MAT) */
rfaTypeId: number; rfaTypeId: number;
@@ -24,8 +24,8 @@ export interface CreateRfaDto {
/** Contract UUID (optional) */ /** Contract UUID (optional) */
contractId?: string; // ADR-019: Contract UUID contractId?: string; // ADR-019: Contract UUID
/** ส่งถึงใคร (สำหรับ Routing Step 1) */ /** ส่งถึงใคร (สำหรับ Routing Step 1) (ADR-019: UUID string only) */
toOrganizationId: number | string; // ADR-019: Accept UUID toOrganizationId: string;
/** รายละเอียดเพิ่มเติม */ /** รายละเอียดเพิ่มเติม */
description?: string; description?: string;
@@ -48,8 +48,8 @@ export type UpdateRfaDto = Partial<CreateRfaDto>;
// --- Search --- // --- Search ---
export interface SearchRfaDto { export interface SearchRfaDto {
/** Filter by Project ID or UUID (optional to allow cross-project search) */ /** Filter by Project UUID (ADR-019: UUID string only) */
projectId?: number | string; // ADR-019: Accept UUID projectId?: string;
/** กรองตามประเภท RFA */ /** กรองตามประเภท RFA */
rfaTypeId?: number; rfaTypeId?: number;
@@ -9,12 +9,12 @@ export enum TransmittalPurpose {
// --- Create --- // --- Create ---
export interface CreateTransmittalDto { export interface CreateTransmittalDto {
projectId?: number | string; // ADR-019: Accept UUID projectId?: string; // ADR-019: UUID string only
recipientOrganizationId?: number | string; // ADR-019: Accept UUID recipientOrganizationId?: string; // ADR-019: UUID string only
subject: string; subject: string;
purpose?: string; purpose?: string;
remarks?: string; remarks?: string;
correspondenceId: number | string; // ADR-019: Accept UUID correspondenceId: string; // ADR-019: UUID string only
items: CreateTransmittalItemDto[]; items: CreateTransmittalItemDto[];
} }
@@ -30,7 +30,7 @@ export type UpdateTransmittalDto = Partial<CreateTransmittalDto>;
// --- Search --- // --- Search ---
export interface SearchTransmittalDto { export interface SearchTransmittalDto {
/** บังคับระบุ Project */ /** บังคับระบุ Project */
projectId: number | string; // ADR-019: Accept UUID projectId: string; // ADR-019: UUID string only
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
+1 -1
View File
@@ -8,7 +8,7 @@ export interface CreateUserDto {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
lineId?: string; lineId?: string;
primaryOrganizationId?: number | string; // ADR-019: Accept UUID primaryOrganizationId?: string; // ADR-019: UUID string only
isActive?: boolean; isActive?: boolean;
} }
+2 -2
View File
@@ -54,9 +54,9 @@ export interface RFA {
} }
export interface CreateRFADto { export interface CreateRFADto {
projectId: number | string; // ADR-019: Accept UUID projectId: string; // ADR-019: UUID string only
contractId?: string; // ADR-019: Contract UUID contractId?: string; // ADR-019: Contract UUID
toOrganizationId?: number | string; // ADR-019: Recipient org UUID toOrganizationId?: string; // ADR-019: UUID string only
rfaTypeId: number; rfaTypeId: number;
disciplineId?: number; disciplineId?: number;
subject: string; subject: string;
+5 -5
View File
@@ -30,7 +30,7 @@ export interface TransmittalItem {
export interface Transmittal { export interface Transmittal {
uuid: string; // ADR-019: from correspondence.uuid uuid: string; // ADR-019: from correspondence.uuid
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
correspondenceId?: number | string; correspondenceId?: string; // ADR-019: UUID string only
transmittalNo: string; transmittalNo: string;
subject: string; subject: string;
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
@@ -74,9 +74,9 @@ export interface CreateTransmittalItemDto {
* DTO for creating a transmittal * DTO for creating a transmittal
*/ */
export interface CreateTransmittalDto { export interface CreateTransmittalDto {
projectId?: number | string; // ADR-019: Accept UUID projectId?: string; // ADR-019: UUID string only
recipientOrganizationId?: number | string; // ADR-019: Accept UUID recipientOrganizationId?: string; // ADR-019: UUID string only
correspondenceId: number | string; // ADR-019: Accept UUID correspondenceId: string; // ADR-019: UUID string only
subject: string; subject: string;
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
remarks?: string; remarks?: string;
@@ -89,6 +89,6 @@ export interface CreateTransmittalDto {
export interface SearchTransmittalDto { export interface SearchTransmittalDto {
page?: number; page?: number;
limit?: number; limit?: number;
projectId?: number | string; // ADR-019: Accept UUID projectId?: string; // ADR-019: UUID string only
search?: string; search?: string;
} }
+7 -8
View File
@@ -5,10 +5,9 @@ export interface Role {
} }
export interface UserOrganization { export interface UserOrganization {
organizationId: number; uuid: string; // ADR-019: Public identifier
orgCode: string; organizationCode: string; // Matches backend Organization entity
orgName: string; organizationName: string; // Matches backend Organization entity
orgNameTh?: string;
} }
export interface User { export interface User {
@@ -20,8 +19,8 @@ export interface User {
lastName: string; lastName: string;
isActive: boolean; isActive: boolean;
lineId?: string; lineId?: string;
primaryOrganizationId?: number | string; // ADR-019: May be INT or UUID organization?: UserOrganization; // ADR-019: use organization.uuid — never expose INT id
organization?: UserOrganization;
roles?: Role[]; roles?: Role[];
// Security fields (from backend v1.5.1) // Security fields (from backend v1.5.1)
@@ -42,7 +41,7 @@ export interface CreateUserDto {
password?: string; password?: string;
isActive: boolean; isActive: boolean;
lineId?: string; lineId?: string;
primaryOrganizationId?: number | string; // ADR-019: Accept UUID primaryOrganizationId?: string; // ADR-019: UUID string only
roleIds: number[]; roleIds: number[];
} }
@@ -53,5 +52,5 @@ export interface SearchUserDto {
limit?: number; limit?: number;
search?: string; search?: string;
roleId?: number; roleId?: number;
primaryOrganizationId?: number | string; // ADR-019: Accept UUID primaryOrganizationId?: string; // ADR-019: UUID string only
} }