gohorsejobs/frontend/src/lib/api.ts
2025-12-22 19:51:54 -03:00

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],
};
}