378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import { getToken } from "./auth";
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
|
|
|
|
export interface ApiUser {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
identifier: string;
|
|
phone?: string;
|
|
role: string;
|
|
status: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ApiCompany {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
email?: string;
|
|
phone?: string;
|
|
website?: string;
|
|
address?: string;
|
|
active: boolean;
|
|
verified: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
async function apiRequest<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const token = getToken();
|
|
|
|
// Sanitize API_URL: remove trailing slash
|
|
let baseUrl = API_URL.replace(/\/+$/, "");
|
|
|
|
// Sanitize endpoint: ensure leading slash
|
|
let cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
|
|
// Detect and fix double prefixing of /api/v1
|
|
// Case 1: BaseURL ends with /api/v1 AND endpoint starts with /api/v1
|
|
if (baseUrl.endsWith("/api/v1") && cleanEndpoint.startsWith("/api/v1")) {
|
|
cleanEndpoint = cleanEndpoint.replace("/api/v1", "");
|
|
}
|
|
|
|
// Case 2: Double /api/v1 inside endpoint itself (if passed incorrectly)
|
|
if (cleanEndpoint.includes("/api/v1/api/v1")) {
|
|
cleanEndpoint = cleanEndpoint.replace("/api/v1/api/v1", "/api/v1");
|
|
}
|
|
|
|
const url = `${baseUrl}${cleanEndpoint}`;
|
|
console.log(`[API Request] ${url}`); // Debug log
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token && { Authorization: `Bearer ${token}` }),
|
|
...options.headers,
|
|
},
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const error = await res.text();
|
|
throw new Error(error || `API Error: ${res.status}`);
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
type CrudAction = "create" | "read" | "update" | "delete";
|
|
|
|
const logCrudAction = (action: CrudAction, resource: string, details?: unknown) => {
|
|
const detailPayload = details ? { details } : undefined;
|
|
console.log(`[CRUD:${action.toUpperCase()}] ${resource}`, detailPayload ?? "");
|
|
};
|
|
|
|
// Users API
|
|
export const usersApi = {
|
|
list: (params?: { page?: number; limit?: number }) => {
|
|
logCrudAction("read", "users", params);
|
|
const query = new URLSearchParams();
|
|
if (params?.page) query.set("page", String(params.page));
|
|
if (params?.limit) query.set("limit", String(params.limit));
|
|
const queryStr = query.toString();
|
|
return apiRequest<PaginatedResponse<ApiUser>>(`/api/v1/users${queryStr ? `?${queryStr}` : ""}`);
|
|
},
|
|
|
|
create: (data: { name: string; email: string; password: string; role: string }) => {
|
|
logCrudAction("create", "users", { name: data.name, email: data.email, role: data.role });
|
|
return apiRequest<ApiUser>("/api/v1/users", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
|
|
delete: (id: string) => {
|
|
logCrudAction("delete", "users", { id });
|
|
return apiRequest<void>(`/api/v1/users/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
};
|
|
|
|
// Companies API
|
|
export const companiesApi = {
|
|
list: () => {
|
|
logCrudAction("read", "companies");
|
|
return apiRequest<ApiCompany[]>("/api/v1/companies");
|
|
},
|
|
|
|
create: (data: { name: string; slug: string; email?: string }) => {
|
|
logCrudAction("create", "companies", data);
|
|
return apiRequest<ApiCompany>("/api/v1/companies", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
};
|
|
|
|
// Jobs API (public)
|
|
export interface ApiJob {
|
|
id: number;
|
|
companyId: number;
|
|
createdBy: number;
|
|
title: string;
|
|
description: string;
|
|
salaryMin?: number;
|
|
salaryMax?: number;
|
|
salaryType?: string;
|
|
employmentType?: string;
|
|
workMode?: string;
|
|
workingHours?: string;
|
|
location?: string;
|
|
regionId?: number;
|
|
cityId?: number;
|
|
requirements?: Record<string, unknown> | string[];
|
|
benefits?: Record<string, unknown> | string[];
|
|
visaSupport: boolean;
|
|
languageLevel?: string;
|
|
status: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
companyName?: string;
|
|
companyLogoUrl?: string;
|
|
regionName?: string;
|
|
cityName?: string;
|
|
}
|
|
|
|
export interface PaginatedResponse<T> {
|
|
data: T[];
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
};
|
|
}
|
|
|
|
export const jobsApi = {
|
|
list: (params?: { page?: number; limit?: number; companyId?: number }) => {
|
|
logCrudAction("read", "jobs", params);
|
|
const query = new URLSearchParams();
|
|
if (params?.page) query.set('page', String(params.page));
|
|
if (params?.limit) query.set('limit', String(params.limit));
|
|
if (params?.companyId) query.set('companyId', String(params.companyId));
|
|
const queryStr = query.toString();
|
|
return apiRequest<PaginatedResponse<ApiJob>>(`/jobs${queryStr ? `?${queryStr}` : ''}`);
|
|
},
|
|
|
|
getById: (id: number) => {
|
|
logCrudAction("read", "jobs", { id });
|
|
return apiRequest<ApiJob>(`/jobs/${id}`);
|
|
},
|
|
};
|
|
|
|
// Admin Backoffice API
|
|
export interface AdminRoleAccess {
|
|
role: string;
|
|
description: string;
|
|
actions: string[];
|
|
}
|
|
|
|
export interface AdminLoginAudit {
|
|
id: number;
|
|
userId: string;
|
|
identifier: string;
|
|
roles: string;
|
|
ipAddress?: string;
|
|
userAgent?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface AdminCompany extends ApiCompany {}
|
|
|
|
export interface AdminJob extends ApiJob {}
|
|
|
|
export interface AdminTag {
|
|
id: number;
|
|
name: string;
|
|
category: "area" | "level" | "stack";
|
|
active: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface AdminCandidateApplication {
|
|
id: number;
|
|
jobTitle: string;
|
|
company: string;
|
|
status: "pending" | "reviewed" | "shortlisted" | "rejected" | "hired";
|
|
appliedAt: string;
|
|
}
|
|
|
|
export interface AdminCandidate {
|
|
id: number;
|
|
name: string;
|
|
email?: string;
|
|
phone?: string;
|
|
location?: string;
|
|
title?: string;
|
|
experience?: string;
|
|
avatarUrl?: string;
|
|
bio?: string;
|
|
skills: string[];
|
|
applications: AdminCandidateApplication[];
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface AdminCandidateStats {
|
|
totalCandidates: number;
|
|
newCandidates: number;
|
|
activeApplications: number;
|
|
hiringRate: number;
|
|
}
|
|
|
|
export interface AdminCandidateListResponse {
|
|
stats: AdminCandidateStats;
|
|
candidates: AdminCandidate[];
|
|
}
|
|
|
|
export const adminAccessApi = {
|
|
listRoles: () => {
|
|
logCrudAction("read", "admin/access/roles");
|
|
return apiRequest<AdminRoleAccess[]>("/api/v1/admin/access/roles");
|
|
},
|
|
};
|
|
|
|
export const adminAuditApi = {
|
|
listLogins: (limit = 50) => {
|
|
logCrudAction("read", "admin/audit/logins", { limit });
|
|
return apiRequest<AdminLoginAudit[]>(`/api/v1/admin/audit/logins?limit=${limit}`);
|
|
},
|
|
};
|
|
|
|
export const adminCandidatesApi = {
|
|
list: () => {
|
|
logCrudAction("read", "admin/candidates");
|
|
return apiRequest<AdminCandidateListResponse>("/api/v1/admin/candidates");
|
|
},
|
|
};
|
|
|
|
export const adminCompaniesApi = {
|
|
list: (verified?: boolean) => {
|
|
logCrudAction("read", "admin/companies", typeof verified === "boolean" ? { verified } : undefined);
|
|
const query = typeof verified === "boolean" ? `?verified=${verified}` : "";
|
|
return apiRequest<AdminCompany[]>(`/api/v1/admin/companies${query}`);
|
|
},
|
|
updateStatus: (id: number, data: { active?: boolean; verified?: boolean }) => {
|
|
logCrudAction("update", "admin/companies", { id, ...data });
|
|
return apiRequest<AdminCompany>(`/api/v1/admin/companies/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
};
|
|
|
|
export const adminJobsApi = {
|
|
list: (params?: { page?: number; limit?: number; status?: string }) => {
|
|
logCrudAction("read", "admin/jobs", params);
|
|
const query = new URLSearchParams();
|
|
if (params?.page) query.set("page", String(params.page));
|
|
if (params?.limit) query.set("limit", String(params.limit));
|
|
if (params?.status) query.set("status", params.status);
|
|
const queryStr = query.toString();
|
|
return apiRequest<PaginatedResponse<AdminJob>>(`/api/v1/admin/jobs${queryStr ? `?${queryStr}` : ""}`);
|
|
},
|
|
updateStatus: (id: number, status: string) => {
|
|
logCrudAction("update", "admin/jobs/status", { id, status });
|
|
return apiRequest<AdminJob>(`/api/v1/admin/jobs/${id}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
},
|
|
duplicate: (id: number) => {
|
|
logCrudAction("create", "admin/jobs/duplicate", { id });
|
|
return apiRequest<AdminJob>(`/api/v1/admin/jobs/${id}/duplicate`, {
|
|
method: "POST",
|
|
});
|
|
},
|
|
};
|
|
|
|
export const adminTagsApi = {
|
|
list: (category?: "area" | "level" | "stack") => {
|
|
logCrudAction("read", "admin/tags", category ? { category } : undefined);
|
|
const query = category ? `?category=${category}` : "";
|
|
return apiRequest<AdminTag[]>(`/api/v1/admin/tags${query}`);
|
|
},
|
|
create: (data: { name: string; category: "area" | "level" | "stack" }) => {
|
|
logCrudAction("create", "admin/tags", data);
|
|
return apiRequest<AdminTag>("/api/v1/admin/tags", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
update: (id: number, data: { name?: string; active?: boolean }) => {
|
|
logCrudAction("update", "admin/tags", { id, ...data });
|
|
return apiRequest<AdminTag>(`/api/v1/admin/tags/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
};
|
|
|
|
// Transform API job to frontend Job format
|
|
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
|
|
// Format salary
|
|
let salary: string | undefined;
|
|
if (apiJob.salaryMin && apiJob.salaryMax) {
|
|
salary = `R$ ${apiJob.salaryMin.toLocaleString('en-US')} - R$ ${apiJob.salaryMax.toLocaleString('en-US')}`;
|
|
} else if (apiJob.salaryMin) {
|
|
salary = `From R$ ${apiJob.salaryMin.toLocaleString('en-US')}`;
|
|
} else if (apiJob.salaryMax) {
|
|
salary = `Up to R$ ${apiJob.salaryMax.toLocaleString('en-US')}`;
|
|
}
|
|
|
|
// Determine type
|
|
type JobType = 'full-time' | 'part-time' | 'contract' | 'remote';
|
|
let type: JobType = 'full-time';
|
|
if (apiJob.employmentType === 'full-time') type = 'full-time';
|
|
else if (apiJob.employmentType === 'part-time') type = 'part-time';
|
|
else if (apiJob.employmentType === 'contract') type = 'contract';
|
|
else if (apiJob.workMode === 'remote' || apiJob.location?.toLowerCase().includes('remote') || apiJob.location?.toLowerCase().includes('remoto')) {
|
|
type = 'remote';
|
|
}
|
|
|
|
// Extract requirements
|
|
const requirements: string[] = [];
|
|
if (apiJob.requirements) {
|
|
if (Array.isArray(apiJob.requirements)) {
|
|
requirements.push(...apiJob.requirements.map(String));
|
|
} else if (typeof apiJob.requirements === 'object') {
|
|
Object.values(apiJob.requirements).forEach(v => requirements.push(String(v)));
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: String(apiJob.id),
|
|
title: apiJob.title,
|
|
company: apiJob.companyName || 'Company',
|
|
location: apiJob.location || apiJob.cityName || 'Location not provided',
|
|
type,
|
|
workMode: apiJob.workMode as any,
|
|
salary,
|
|
description: apiJob.description,
|
|
requirements: requirements.length > 0 ? requirements : ['View details'],
|
|
postedAt: apiJob.createdAt?.split('T')[0] || new Date().toISOString().split('T')[0],
|
|
};
|
|
}
|