backstory/frontend/src/services/api-client.ts
2025-06-18 14:26:07 -07:00

2023 lines
53 KiB
TypeScript

/**
* API Client with Streaming Support and Date Conversion
*
* This demonstrates how to use the generated types with the conversion utilities
* for seamless frontend-backend communication, including streaming responses and
* automatic date field conversion.
*/
// Import generated types (from running generate_types.py)
import * as Types from "types/types";
import {
formatApiRequest,
parseApiResponse,
parsePaginatedResponse,
handleApiResponse,
// handlePaginatedApiResponse,
createPaginatedRequest,
toUrlParams,
extractApiData,
// ApiResponse,
PaginatedResponse,
PaginatedRequest,
toSnakeCase,
} from "types/conversion";
// Import generated date conversion functions
import { convertFromApi, convertArrayFromApi } from "types/types";
const TOKEN_STORAGE = {
ACCESS_TOKEN: "accessToken",
REFRESH_TOKEN: "refreshToken",
USER_DATA: "userData",
TOKEN_EXPIRY: "tokenExpiry",
USER_TYPE: "userType",
IS_GUEST: "isGuest",
PENDING_VERIFICATION_EMAIL: "pendingVerificationEmail",
} as const;
// ============================
// Streaming Types and Interfaces
// ============================
export interface GuestConversionRequest extends CreateCandidateRequest {
accountType: "candidate";
}
export class RateLimitError extends Error {
constructor(
message: string,
public retryAfterSeconds: number,
public remainingRequests: Record<string, number>
) {
super(message);
this.name = "RateLimitError";
}
}
interface StreamingOptions<T = Types.ChatMessage> {
method?: string;
headers?: Record<string, any>;
onStatus?: (status: Types.ChatMessageStatus) => void;
onMessage?: (message: T) => void;
onStreaming?: (chunk: Types.ChatMessageStreaming) => void;
onComplete?: () => void;
onError?: (error: Types.ChatMessageError) => void;
onWarn?: (warning: string) => void;
signal?: AbortSignal;
}
interface DeleteResponse {
success: boolean;
message: string;
}
interface StreamingResponse<T = Types.ChatMessage> {
messageId: string;
cancel: () => void;
promise: Promise<T>;
}
interface CreateCandidateAIResponse {
message: string;
candidate: Types.CandidateAI;
resume: string;
}
export interface PasswordResetRequest {
email: string;
}
export interface PasswordResetConfirm {
token: string;
newPassword: string;
}
// ============================
// Chat Types and Interfaces
// ============================
export interface CandidateInfo {
id: string;
name: string;
email: string;
username: string;
skills: string[];
experience: number;
location: string;
}
export interface CreateChatSessionRequest {
username?: string; // Optional candidate username to associate with
context: Types.ChatContext;
title?: string;
}
export interface UpdateChatSessionRequest {
title?: string;
context?: Partial<Types.ChatContext>;
isArchived?: boolean;
systemPrompt?: string;
}
export interface CandidateSessionsResponse {
candidate: {
id: string;
username: string;
fullName: string;
email: string;
};
sessions: PaginatedResponse<Types.ChatSession>;
}
// ============================
// API Client Class
// ============================
class ApiClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
constructor(accessToken?: string) {
const loc = window.location;
if (!loc.host.match(/.*battle-linux.*/)) {
this.baseUrl = loc.protocol + "//" + loc.host + "/api/1.0";
} else {
this.baseUrl = loc.protocol + "//battle-linux.ketrenos.com:8912/api/1.0";
}
this.defaultHeaders = {
"Content-Type": "application/json",
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
};
}
// ============================
// Response Handlers with Date Conversion
// ============================
/**
* Handle API response with automatic date conversion for specific model types
*/
private async handleApiResponseWithConversion<T>(
response: Response,
modelType?: string
): Promise<T> {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
const apiResponse = parseApiResponse<T>(data);
const extractedData = extractApiData(apiResponse);
// Apply model-specific date conversion if modelType is provided
if (modelType) {
return convertFromApi<T>(extractedData, modelType);
}
return extractedData;
}
/**
* Handle paginated API response with automatic date conversion
*/
private async handlePaginatedApiResponseWithConversion<T>(
response: Response,
modelType?: string
): Promise<PaginatedResponse<T>> {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
const apiResponse = parsePaginatedResponse<T>(data);
const extractedData = extractApiData(apiResponse);
// Apply model-specific date conversion to array items if modelType is provided
if (modelType && extractedData.data) {
return {
...extractedData,
data: convertArrayFromApi<T>(extractedData.data, modelType),
};
}
return extractedData;
}
/**
* Create candidate with email verification
*/
async createCandidate(
candidate: CreateCandidateRequest
): Promise<RegistrationResponse> {
const response = await fetch(`${this.baseUrl}/candidates`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(candidate)),
});
return handleApiResponse<RegistrationResponse>(response);
}
async createCandidateAI(
userMessage: Types.ChatMessageUser
): Promise<CreateCandidateAIResponse> {
const response = await fetch(`${this.baseUrl}/candidates/ai`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(userMessage)),
});
const result = await handleApiResponse<CreateCandidateAIResponse>(response);
return {
message: result.message,
candidate: convertFromApi<Types.CandidateAI>(
result.candidate,
"CandidateAI"
),
resume: result.resume,
};
}
/**
* Create employer with email verification
*/
async createEmployerWithVerification(
employer: CreateEmployerRequest
): Promise<RegistrationResponse> {
const response = await fetch(`${this.baseUrl}/employers`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(employer)),
});
return handleApiResponse<RegistrationResponse>(response);
}
/**
* Verify email address
*/
async verifyEmail(
request: EmailVerificationRequest
): Promise<EmailVerificationResponse> {
const response = await fetch(`${this.baseUrl}/auth/verify-email`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
});
return handleApiResponse<EmailVerificationResponse>(response);
}
/**
* Resend verification email
*/
async resendVerificationEmail(
request: ResendVerificationRequest
): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/auth/resend-verification`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
});
return handleApiResponse<{ message: string }>(response);
}
/**
* Request MFA for new device
*/
async requestMFA(request: MFARequest): Promise<Types.MFARequestResponse> {
const response = await fetch(`${this.baseUrl}/auth/mfa/request`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
});
return handleApiResponse<Types.MFARequestResponse>(response);
}
/**
* Verify MFA code
*/
async verifyMFA(
request: Types.MFAVerifyRequest
): Promise<Types.AuthResponse> {
const formattedRequest = formatApiRequest(request);
const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formattedRequest),
});
return handleApiResponse<Types.AuthResponse>(response);
}
/**
* login with device detection
*/
async login(
auth: Types.LoginRequest
): Promise<Types.AuthResponse | Types.MFARequestResponse> {
const response = await fetch(`${this.baseUrl}/auth/login`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(auth)),
});
return handleApiResponse<Types.AuthResponse | Types.MFARequestResponse>(
response
);
}
/**
* Logout with token revocation
*/
async logout(
accessToken: string,
refreshToken: string
): Promise<{ message: string; tokensRevoked: any }> {
const response = await fetch(`${this.baseUrl}/auth/logout`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(
formatApiRequest({
accessToken,
refreshToken,
})
),
});
return handleApiResponse<{ message: string; tokensRevoked: any }>(response);
}
/**
* Logout from all devices
*/
async logoutAllDevices(): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/auth/logout-all`, {
method: "POST",
headers: this.defaultHeaders,
});
return handleApiResponse<{ message: string }>(response);
}
// ============================
// Device Management Methods
// ============================
/**
* Get trusted devices for current user
*/
async getTrustedDevices(): Promise<TrustedDevice[]> {
const response = await fetch(`${this.baseUrl}/auth/trusted-devices`, {
headers: this.defaultHeaders,
});
return handleApiResponse<TrustedDevice[]>(response);
}
/**
* Remove trusted device
*/
async removeTrustedDevice(deviceId: string): Promise<{ message: string }> {
const response = await fetch(
`${this.baseUrl}/auth/trusted-devices/${deviceId}`,
{
method: "DELETE",
headers: this.defaultHeaders,
}
);
return handleApiResponse<{ message: string }>(response);
}
/**
* Get security log for current user
*/
async getSecurityLog(days = 7): Promise<SecurityEvent[]> {
const response = await fetch(
`${this.baseUrl}/auth/security-log?days=${days}`,
{
headers: this.defaultHeaders,
}
);
return handleApiResponse<SecurityEvent[]>(response);
}
// ============================
// Admin Methods (if user has admin role)
// ============================
/**
* Get pending user verifications (admin only)
*/
async getPendingVerifications(): Promise<PendingVerification[]> {
const response = await fetch(
`${this.baseUrl}/admin/pending-verifications`,
{
headers: this.defaultHeaders,
}
);
return handleApiResponse<PendingVerification[]>(response);
}
/**
* Manually verify user (admin only)
*/
async manuallyVerifyUser(
userId: string,
reason: string
): Promise<{ message: string }> {
const response = await fetch(
`${this.baseUrl}/admin/verify-user/${userId}`,
{
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ reason })),
}
);
return handleApiResponse<{ message: string }>(response);
}
/**
* Get user security events (admin only)
*/
async getUserSecurityEvents(
userId: string,
days = 30
): Promise<SecurityEvent[]> {
const response = await fetch(
`${this.baseUrl}/admin/users/${userId}/security-events?days=${days}`,
{
headers: this.defaultHeaders,
}
);
return handleApiResponse<SecurityEvent[]>(response);
}
// ============================
// Utility Methods
// ============================
/**
* Generate device fingerprint for MFA
*/
generateDeviceFingerprint(): string {
// Create a basic device fingerprint
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.textBaseline = "top";
ctx.font = "14px Arial";
ctx.fillText("Device fingerprint", 2, 2);
}
const fingerprint =
(canvas.toDataURL() || "") +
navigator.userAgent +
navigator.language +
// screen.width + 'x' + screen.height +
// (navigator.platform || '') +
(navigator.cookieEnabled ? "1" : "0");
// Create a hash-like string from the fingerprint
let hash = 0;
for (let i = 0; i < fingerprint.length; i++) {
const char = fingerprint.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16).slice(0, 16);
}
/**
* Get device name from user agent
*/
getDeviceName(): string {
const ua = navigator.userAgent;
const browser = ua.includes("Chrome")
? "Chrome"
: ua.includes("Firefox")
? "Firefox"
: ua.includes("Safari")
? "Safari"
: ua.includes("Edge")
? "Edge"
: "Browser";
const os = ua.includes("Windows")
? "Windows"
: ua.includes("Mac")
? "macOS"
: ua.includes("Linux")
? "Linux"
: ua.includes("Android")
? "Android"
: ua.includes("iOS")
? "iOS"
: "Unknown OS";
return `${browser} on ${os}`;
}
/**
* Check if email verification is pending
*/
isEmailVerificationPending(): boolean {
return localStorage.getItem("pendingEmailVerification") === "true";
}
/**
* Set email verification pending status
*/
setPendingEmailVerification(email: string, pending = true): void {
if (pending) {
localStorage.setItem("pendingEmailVerification", "true");
localStorage.setItem("pendingVerificationEmail", email);
} else {
localStorage.removeItem("pendingEmailVerification");
localStorage.removeItem("pendingVerificationEmail");
}
}
/**
* Get pending verification email
*/
getPendingVerificationEmail(): string | null {
return localStorage.getItem("pendingVerificationEmail");
}
// ============================
// Authentication Methods
// ============================
async refreshToken(refreshToken: string): Promise<Types.AuthResponse> {
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ refreshToken })),
});
return handleApiResponse<Types.AuthResponse>(response);
}
// ============================
// Candidate Methods with Date Conversion
// ============================
// reference can be candidateId, username, or email
async getCandidate(reference: string): Promise<Types.Candidate> {
const response = await fetch(`${this.baseUrl}/candidates/${reference}`, {
headers: this.defaultHeaders,
});
return this.handleApiResponseWithConversion<Types.Candidate>(
response,
"Candidate"
);
}
async updateCandidate(
id: string,
updates: Partial<Types.Candidate>
): Promise<Types.Candidate> {
const request = formatApiRequest(updates);
const response = await fetch(`${this.baseUrl}/candidates/${id}`, {
method: "PATCH",
headers: this.defaultHeaders,
body: JSON.stringify(request),
});
return this.handleApiResponseWithConversion<Types.Candidate>(
response,
"Candidate"
);
}
async deleteCandidate(id: string): Promise<DeleteResponse> {
const response = await fetch(`${this.baseUrl}/candidates/${id}`, {
method: "DELETE",
headers: this.defaultHeaders,
body: JSON.stringify({ id }),
});
return handleApiResponse<DeleteResponse>(response);
}
async deleteJob(id: string): Promise<DeleteResponse> {
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
method: "DELETE",
headers: this.defaultHeaders,
body: JSON.stringify({ id }),
});
return handleApiResponse<DeleteResponse>(response);
}
async uploadCandidateProfile(file: File): Promise<boolean> {
const formData = new FormData();
formData.append("file", file);
formData.append("filename", file.name);
const response = await fetch(`${this.baseUrl}/candidates/profile/upload`, {
method: "POST",
headers: {
// Don't set Content-Type - browser will set it automatically with boundary
Authorization: this.defaultHeaders["Authorization"],
},
body: formData,
});
const result = await handleApiResponse<boolean>(response);
return result;
}
async getCandidates(
request: Partial<PaginatedRequest> = {}
): Promise<PaginatedResponse<Types.Candidate>> {
const paginatedRequest = createPaginatedRequest(request);
const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(`${this.baseUrl}/candidates?${params}`, {
headers: this.defaultHeaders,
});
return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(
response,
"Candidate"
);
}
async searchCandidates(
query: string,
filters?: Record<string, any>
): Promise<PaginatedResponse<Types.Candidate>> {
const searchRequest = {
query,
filters,
page: 1,
limit: 20,
};
const params = toUrlParams(formatApiRequest(searchRequest));
const response = await fetch(
`${this.baseUrl}/candidates/search?${params}`,
{
headers: this.defaultHeaders,
}
);
return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(
response,
"Candidate"
);
}
// ============================
// Employer Methods with Date Conversion
// ============================
async createEmployer(
request: CreateEmployerRequest
): Promise<Types.Employer> {
const response = await fetch(`${this.baseUrl}/employers`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
});
return this.handleApiResponseWithConversion<Types.Employer>(
response,
"Employer"
);
}
async getEmployer(id: string): Promise<Types.Employer> {
const response = await fetch(`${this.baseUrl}/employers/${id}`, {
headers: this.defaultHeaders,
});
return this.handleApiResponseWithConversion<Types.Employer>(
response,
"Employer"
);
}
async updateEmployer(
id: string,
updates: Partial<Types.Employer>
): Promise<Types.Employer> {
const response = await fetch(`${this.baseUrl}/employers/${id}`, {
method: "PATCH",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(updates)),
});
return this.handleApiResponseWithConversion<Types.Employer>(
response,
"Employer"
);
}
// ============================
// Job Methods with Date Conversion
// ============================
async updateJob(id: string, updates: Partial<Types.Job>): Promise<Types.Job> {
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
method: "PATCH",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(updates)),
});
return this.handleApiResponseWithConversion<Types.Job>(response, "Job");
}
createJobFromDescription(
job_description: string,
streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>
): StreamingResponse<Types.JobRequirementsMessage> {
const body = JSON.stringify(job_description);
return this.streamify<Types.JobRequirementsMessage>(
"/jobs/from-content",
body,
streamingOptions,
"JobRequirementsMessage"
);
}
async createJob(
job: Omit<Types.Job, "id" | "datePosted" | "views" | "applicationCount">
): Promise<Types.Job> {
const body = JSON.stringify(formatApiRequest(job));
const response = await fetch(`${this.baseUrl}/jobs`, {
method: "POST",
headers: this.defaultHeaders,
body: body,
});
return this.handleApiResponseWithConversion<Types.Job>(response, "Job");
}
saveResume(
candidate_id: string,
job_id: string,
resume: string,
streamingOptions?: StreamingOptions<Types.ResumeMessage>
): StreamingResponse<Types.ResumeMessage> {
const body = JSON.stringify(resume);
return this.streamify<Types.ResumeMessage>(
`/resumes/${candidate_id}/${job_id}`,
body,
streamingOptions,
"Resume"
);
}
// Additional API methods for Resume management
async getResumes(): Promise<{
success: boolean;
resumes: Types.Resume[];
count: number;
}> {
const response = await fetch(`${this.baseUrl}/resumes`, {
headers: this.defaultHeaders,
});
return handleApiResponse<{
success: boolean;
resumes: Types.Resume[];
count: number;
}>(response);
}
async getResume(
resumeId: string
): Promise<{ success: boolean; resume: Types.Resume }> {
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
headers: this.defaultHeaders,
});
return handleApiResponse<{ success: boolean; resume: Types.Resume }>(
response
);
}
async deleteResume(
resumeId: string
): Promise<{ success: boolean; message: string }> {
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
method: "DELETE",
headers: this.defaultHeaders,
});
return handleApiResponse<{ success: boolean; message: string }>(response);
}
async getResumesByCandidate(candidateId: string): Promise<{
success: boolean;
candidateId: string;
resumes: Types.Resume[];
count: number;
}> {
const response = await fetch(
`${this.baseUrl}/resumes/candidate/${candidateId}`,
{
headers: this.defaultHeaders,
}
);
return handleApiResponse<{
success: boolean;
candidateId: string;
resumes: Types.Resume[];
count: number;
}>(response);
}
async getResumesByJob(jobId: string): Promise<{
success: boolean;
jobId: string;
resumes: Types.Resume[];
count: number;
}> {
const response = await fetch(`${this.baseUrl}/resumes/job/${jobId}`, {
headers: this.defaultHeaders,
});
return handleApiResponse<{
success: boolean;
jobId: string;
resumes: Types.Resume[];
count: number;
}>(response);
}
async searchResumes(query: string): Promise<{
success: boolean;
query: string;
resumes: Types.Resume[];
count: number;
}> {
const params = new URLSearchParams({ q: query });
const response = await fetch(`${this.baseUrl}/resumes/search?${params}`, {
headers: this.defaultHeaders,
});
return handleApiResponse<{
success: boolean;
query: string;
resumes: Types.Resume[];
count: number;
}>(response);
}
async getResumeStatistics(): Promise<{ success: boolean; statistics: any }> {
const response = await fetch(`${this.baseUrl}/resumes/stats`, {
headers: this.defaultHeaders,
});
return handleApiResponse<{ success: boolean; statistics: any }>(response);
}
async updateResume(resumeId: string, content: string): Promise<Types.Resume> {
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
method: "PUT",
headers: this.defaultHeaders,
body: JSON.stringify(content),
});
return this.handleApiResponseWithConversion<Types.Resume>(
response,
"Resume"
);
}
async getJob(id: string): Promise<Types.Job> {
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
headers: this.defaultHeaders,
});
return this.handleApiResponseWithConversion<Types.Job>(response, "Job");
}
async getJobs(
request: Partial<PaginatedRequest> = {}
): Promise<PaginatedResponse<Types.Job>> {
const paginatedRequest = createPaginatedRequest(request);
const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(`${this.baseUrl}/jobs?${params}`, {
headers: this.defaultHeaders,
});
return this.handlePaginatedApiResponseWithConversion<Types.Job>(
response,
"Job"
);
}
async getJobsByEmployer(
employerId: string,
request: Partial<PaginatedRequest> = {}
): Promise<PaginatedResponse<Types.Job>> {
const paginatedRequest = createPaginatedRequest(request);
const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(
`${this.baseUrl}/employers/${employerId}/jobs?${params}`,
{
headers: this.defaultHeaders,
}
);
return this.handlePaginatedApiResponseWithConversion<Types.Job>(
response,
"Job"
);
}
async searchJobs(
query: string,
filters?: Record<string, any>
): Promise<PaginatedResponse<Types.Job>> {
const searchRequest = {
query,
filters,
page: 1,
limit: 20,
};
const params = toUrlParams(formatApiRequest(searchRequest));
const response = await fetch(`${this.baseUrl}/jobs/search?${params}`, {
headers: this.defaultHeaders,
});
return this.handlePaginatedApiResponseWithConversion<Types.Job>(
response,
"Job"
);
}
// ============================
// Job Application Methods with Date Conversion
// ============================
async applyToJob(
application: Omit<
Types.JobApplication,
"id" | "appliedDate" | "updatedDate" | "status"
>
): Promise<Types.JobApplication> {
const response = await fetch(`${this.baseUrl}/job-applications`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(application)),
});
return this.handleApiResponseWithConversion<Types.JobApplication>(
response,
"JobApplication"
);
}
async getJobApplication(id: string): Promise<Types.JobApplication> {
const response = await fetch(`${this.baseUrl}/job-applications/${id}`, {
headers: this.defaultHeaders,
});
return this.handleApiResponseWithConversion<Types.JobApplication>(
response,
"JobApplication"
);
}
async getJobApplications(
request: Partial<PaginatedRequest> = {}
): Promise<PaginatedResponse<Types.JobApplication>> {
const paginatedRequest = createPaginatedRequest(request);
const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(`${this.baseUrl}/job-applications?${params}`, {
headers: this.defaultHeaders,
});
return this.handlePaginatedApiResponseWithConversion<Types.JobApplication>(
response,
"JobApplication"
);
}
async updateApplicationStatus(
id: string,
status: Types.ApplicationStatus
): Promise<Types.JobApplication> {
const response = await fetch(
`${this.baseUrl}/job-applications/${id}/status`,
{
method: "PATCH",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ status })),
}
);
return this.handleApiResponseWithConversion<Types.JobApplication>(
response,
"JobApplication"
);
}
// ============================
// Chat Methods with Date Conversion
// ============================
/**
* Create a chat session with optional candidate association
*/
async createChatSessionWithCandidate(
request: CreateChatSessionRequest
): Promise<Types.ChatSession> {
const response = await fetch(`${this.baseUrl}/chat/sessions`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
});
return this.handleApiResponseWithConversion<Types.ChatSession>(
response,
"ChatSession"
);
}
/**
* Get all chat sessions related to a specific candidate
*/
async getCandidateChatSessions(
username: string,
request: Partial<PaginatedRequest> = {}
): Promise<CandidateSessionsResponse> {
const paginatedRequest = createPaginatedRequest(request);
const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(
`${this.baseUrl}/candidates/${username}/chat-sessions?${params}`,
{
headers: this.defaultHeaders,
}
);
// Handle the nested sessions with date conversion
const result =
await this.handleApiResponseWithConversion<CandidateSessionsResponse>(
response
);
// Convert the nested sessions array
if (result.sessions && result.sessions.data) {
result.sessions.data = convertArrayFromApi<Types.ChatSession>(
result.sessions.data,
"ChatSession"
);
}
return result;
}
async getOrCreateChatSession(
candidate: Types.Candidate,
title: string,
context_type: Types.ChatContextType
): Promise<Types.ChatSession> {
const result = await this.getCandidateChatSessions(candidate.username);
/* Find the 'candidate_chat' session if it exists, otherwise create it */
let session = result.sessions.data.find(
(session) => session.title === title
);
if (!session) {
session = await this.createCandidateChatSession(
candidate.username,
context_type,
title
);
}
return session;
}
async getSystemInfo(): Promise<Types.SystemInfo> {
const response = await fetch(`${this.baseUrl}/system-info`, {
method: "GET",
headers: this.defaultHeaders,
});
const result = await handleApiResponse<Types.SystemInfo>(response);
return result;
}
async getCandidateSimilarContent(
query: string
): Promise<Types.ChromaDBGetResponse> {
const response = await fetch(`${this.baseUrl}/candidates/rag-search`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(query),
});
const result = await handleApiResponse<Types.ChromaDBGetResponse>(response);
return result;
}
async getCandidateVectors(
dimensions: number
): Promise<Types.ChromaDBGetResponse> {
const response = await fetch(`${this.baseUrl}/candidates/rag-vectors`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(dimensions),
});
const result = await handleApiResponse<Types.ChromaDBGetResponse>(response);
return result;
}
async getCandidateRAGContent(
documentId: string
): Promise<Types.RagContentResponse> {
const response = await fetch(`${this.baseUrl}/candidates/rag-content`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify({ id: documentId }),
});
const result = await handleApiResponse<Types.RagContentResponse>(response);
return result;
}
/**
uploadCandidateDocument
usage:
const controller : StreamingResponse<Types.Document> = uploadCandidateDocument(...);
const document : Types.Document = await controller.promise;
console.log(`Document id: ${document.id}`)
*/
uploadCandidateDocument(
file: File,
options: Types.DocumentOptions,
streamingOptions?: StreamingOptions<Types.DocumentMessage>
): StreamingResponse<Types.DocumentMessage> {
const convertedOptions = toSnakeCase(options);
const formData = new FormData();
formData.append("file", file);
formData.append("filename", file.name);
formData.append("options", JSON.stringify(convertedOptions));
streamingOptions = {
...streamingOptions,
headers: {
// Don't set Content-Type - browser will set it automatically with boundary
Authorization: this.defaultHeaders["Authorization"],
},
};
return this.streamify<Types.DocumentMessage>(
"/candidates/documents/upload",
formData,
streamingOptions,
"DocumentMessage"
);
}
createJobFromFile(
file: File,
streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>
): StreamingResponse<Types.JobRequirementsMessage> {
const formData = new FormData();
formData.append("file", file);
formData.append("filename", file.name);
streamingOptions = {
...streamingOptions,
headers: {
// Don't set Content-Type - browser will set it automatically with boundary
Authorization: this.defaultHeaders["Authorization"],
},
};
return this.streamify<Types.JobRequirementsMessage>(
"/jobs/upload",
formData,
streamingOptions,
"JobRequirementsMessage"
);
}
getJobRequirements(
jobId: string,
streamingOptions?: StreamingOptions<Types.DocumentMessage>
): StreamingResponse<Types.DocumentMessage> {
streamingOptions = {
...streamingOptions,
headers: this.defaultHeaders,
};
return this.streamify<Types.DocumentMessage>(
`/jobs/requirements/${jobId}`,
null,
streamingOptions,
"DocumentMessage"
);
}
generateResume(
candidateId: string,
jobId: string,
streamingOptions?: StreamingOptions<Types.ChatMessageResume>
): StreamingResponse<Types.ChatMessageResume> {
streamingOptions = {
...streamingOptions,
headers: this.defaultHeaders,
};
return this.streamify<Types.ChatMessageResume>(
`/candidates/${candidateId}/${jobId}/generate-resume`,
null,
streamingOptions,
"ChatMessageResume"
);
}
candidateMatchForRequirement(
candidate_id: string,
requirement: string,
streamingOptions?: StreamingOptions<Types.ChatMessageSkillAssessment>
): StreamingResponse<Types.ChatMessageSkillAssessment> {
const body = JSON.stringify(requirement);
streamingOptions = {
...streamingOptions,
headers: this.defaultHeaders,
};
return this.streamify<Types.ChatMessageSkillAssessment>(
`/candidates/${candidate_id}/skill-match`,
body,
streamingOptions,
"ChatMessageSkillAssessment"
);
}
async updateCandidateDocument(
document: Types.Document
): Promise<Types.Document> {
const request: Types.DocumentUpdateRequest = {
filename: document.filename,
options: document.options,
};
const response = await fetch(
`${this.baseUrl}/candidates/documents/${document.id}`,
{
method: "PATCH",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
}
);
const result = await handleApiResponse<Types.Document>(response);
return result;
}
async deleteCandidateDocument(document: Types.Document): Promise<boolean> {
const response = await fetch(
`${this.baseUrl}/candidates/documents/${document.id}`,
{
method: "DELETE",
headers: this.defaultHeaders,
}
);
const result = await handleApiResponse<boolean>(response);
return result;
}
async getCandidateDocuments(): Promise<Types.DocumentListResponse> {
const response = await fetch(`${this.baseUrl}/candidates/documents`, {
headers: this.defaultHeaders,
});
const result = await handleApiResponse<Types.DocumentListResponse>(
response
);
return result;
}
async getCandidateDocumentText(
document: Types.Document
): Promise<Types.DocumentContentResponse> {
const response = await fetch(
`${this.baseUrl}/candidates/documents/${document.id}/content`,
{
headers: this.defaultHeaders,
}
);
const result = await handleApiResponse<Types.DocumentContentResponse>(
response
);
return result;
}
/**
* Create a chat session about a specific candidate
*/
async createCandidateChatSession(
username: string,
chatType: Types.ChatContextType = "candidate_chat",
title?: string
): Promise<Types.ChatSession> {
const request: CreateChatSessionRequest = {
username,
title: title || `Discussion about ${username}`,
context: {
type: chatType,
additionalContext: {},
},
};
return this.createChatSessionWithCandidate(request);
}
async createChatSession(
context: Types.ChatContext
): Promise<Types.ChatSession> {
const response = await fetch(`${this.baseUrl}/chat/sessions`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ context })),
});
return this.handleApiResponseWithConversion<Types.ChatSession>(
response,
"ChatSession"
);
}
async getChatSession(id: string): Promise<Types.ChatSession> {
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, {
headers: this.defaultHeaders,
});
return this.handleApiResponseWithConversion<Types.ChatSession>(
response,
"ChatSession"
);
}
/**
* Update a chat session's properties
*/
async updateChatSession(
id: string,
updates: UpdateChatSessionRequest
): Promise<Types.ChatSession> {
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, {
method: "PATCH",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(updates)),
});
return this.handleApiResponseWithConversion<Types.ChatSession>(
response,
"ChatSession"
);
}
/**
* Delete a chat session
*/
async deleteChatSession(
id: string
): Promise<{ success: boolean; message: string }> {
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, {
method: "DELETE",
headers: this.defaultHeaders,
});
return handleApiResponse<{ success: boolean; message: string }>(response);
}
// ============================
// Guest Authentication Methods
// ============================
/**
* Create a guest session with authentication
*/
async createGuestSession(): Promise<Types.AuthResponse> {
const response = await fetch(`${this.baseUrl}/auth/guest`, {
method: "POST",
headers: this.defaultHeaders,
});
const result = await handleApiResponse<Types.AuthResponse>(response);
// Convert guest data if needed
if (result.user && result.user.userType === "guest") {
result.user = convertFromApi<Types.Guest>(result.user, "Guest");
}
return result;
}
/**
* Convert guest account to permanent user account
*/
async convertGuestToUser(
registrationData: CreateCandidateRequest & { accountType: "candidate" }
): Promise<{
message: string;
auth: Types.AuthResponse;
conversionType: string;
}> {
const response = await fetch(`${this.baseUrl}/auth/guest/convert`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(registrationData)),
});
const result = await handleApiResponse<{
message: string;
auth: Types.AuthResponse;
conversionType: string;
}>(response);
// Convert the auth user data
if (result.auth?.user) {
result.auth.user = convertFromApi<Types.Candidate>(
result.auth.user,
"Candidate"
);
}
return result;
}
/**
* Check if current session is a guest
*/
isGuestSession(): boolean {
try {
const userDataStr = localStorage.getItem(TOKEN_STORAGE.USER_DATA);
if (userDataStr) {
const userData = JSON.parse(userDataStr);
return userData.userType === "guest";
}
return false;
} catch {
return false;
}
}
/**
* Get guest session info
*/
getGuestSessionInfo(): Types.Guest | null {
try {
const userDataStr = localStorage.getItem(TOKEN_STORAGE.USER_DATA);
if (userDataStr) {
const userData = JSON.parse(userDataStr);
if (userData.userType === "guest") {
return convertFromApi<Types.Guest>(userData, "Guest");
}
}
return null;
} catch {
return null;
}
}
/**
* Get rate limit status for current user
*/
async getRateLimitStatus(): Promise<{
user_id: string;
user_type: string;
is_admin: boolean;
current_usage: Record<string, number>;
limits: Record<string, number>;
remaining: Record<string, number>;
reset_times: Record<string, string>;
config: any;
}> {
const response = await fetch(`${this.baseUrl}/admin/rate-limits/info`, {
headers: this.defaultHeaders,
});
return handleApiResponse<any>(response);
}
/**
* Get guest statistics (admin only)
*/
async getGuestStatistics(): Promise<{
total_guests: number;
active_last_hour: number;
active_last_day: number;
converted_guests: number;
by_ip: Record<string, number>;
creation_timeline: Record<string, number>;
}> {
const response = await fetch(`${this.baseUrl}/admin/guests/statistics`, {
headers: this.defaultHeaders,
});
return handleApiResponse<any>(response);
}
/**
* Cleanup inactive guests (admin only)
*/
async cleanupInactiveGuests(inactiveHours = 24): Promise<{
message: string;
cleaned_count: number;
}> {
const response = await fetch(`${this.baseUrl}/admin/guests/cleanup`, {
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify({ inactive_hours: inactiveHours }),
});
return handleApiResponse<{
message: string;
cleaned_count: number;
}>(response);
}
// ============================
// Enhanced Error Handling for Rate Limits
// ============================
/**
* Enhanced API response handler with rate limit handling
*/
private async handleApiResponseWithRateLimit<T>(
response: Response
): Promise<T> {
if (response.status === 429) {
const rateLimitData = await response.json();
const retryAfter = response.headers.get("Retry-After");
throw new RateLimitError(
rateLimitData.detail?.message || "Rate limit exceeded",
parseInt(retryAfter || "60"),
rateLimitData.detail?.remaining || {}
);
}
return this.handleApiResponseWithConversion<T>(response);
}
async resetChatSession(
id: string
): Promise<{ success: boolean; message: string }> {
const response = await fetch(`${this.baseUrl}/chat/sessions/${id}/reset`, {
method: "PATCH",
headers: this.defaultHeaders,
});
return handleApiResponse<{ success: boolean; message: string }>(response);
}
/**
* streamify<T = Types.ChatMessage[]>
* @param api API entrypoint
* @param data Data to be attached to request Body
* @param options callbacks, headers, and method
* @returns
*/
streamify<T = Types.ChatMessage[]>(
api: string,
data: BodyInit | null,
options: StreamingOptions<T> = {},
modelType?: string
): StreamingResponse<T> {
const abortController = new AbortController();
const signal = options.signal || abortController.signal;
const headers = options.headers || null;
const method = options.method || "POST";
const messageId = "";
let finalMessage: T | null = null;
const promise = new Promise<T>(async (resolve, reject) => {
try {
const response = await fetch(`${this.baseUrl}${api}`, {
method,
headers: headers || {
...this.defaultHeaders,
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
body: data,
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let buffer = "";
let streamingMessage: Types.ChatMessageStreaming | null = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// Stream ended naturally
break;
}
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim() === "") continue; // Skip blank lines between SSEs
try {
if (line.startsWith("data: ")) {
const data = line.slice(5).trim();
const incoming: any = JSON.parse(data);
// Handle different status types
switch (incoming.status) {
case "streaming":
const streaming =
Types.convertChatMessageStreamingFromApi(incoming);
if (streamingMessage === null) {
streamingMessage = { ...streaming };
} else {
// Can't do a simple += as typescript thinks .content might not be there
streamingMessage.content =
(streamingMessage?.content || "") + streaming.content;
// Update timestamp to latest
streamingMessage.timestamp = streaming.timestamp;
}
options.onStreaming?.(streamingMessage);
break;
case "status":
const status =
Types.convertChatMessageStatusFromApi(incoming);
options.onStatus?.(status);
break;
case "error":
const error =
Types.convertChatMessageErrorFromApi(incoming);
options.onError?.(error);
break;
case "done":
const message = (
modelType
? convertFromApi<T>(incoming, modelType)
: incoming
) as T;
finalMessage = message;
try {
options.onMessage?.(message);
} catch (error) {
console.error("onMessage handler failed: ", error);
}
break;
}
}
} catch (error) {
console.warn("Failed to process SSE:", error);
if (error instanceof Error) {
options.onWarn?.(error.message);
}
// Continue processing other lines
}
}
}
} finally {
reader.releaseLock();
}
options.onComplete?.();
resolve(finalMessage as T);
} catch (error) {
if (signal.aborted) {
options.onComplete?.();
reject(new Error("Request was aborted"));
} else {
console.error(error);
options.onError?.({
sessionId: "",
status: "error",
type: "text",
content: (error as Error).message,
});
options.onComplete?.();
reject(error);
}
}
});
return {
messageId,
cancel: () => abortController.abort(),
promise,
};
}
/**
* Send message with streaming response support and date conversion
*/
sendMessageStream(
chatMessage: Types.ChatMessageUser,
options: StreamingOptions = {}
): StreamingResponse {
const body = JSON.stringify(formatApiRequest(chatMessage));
return this.streamify(
`/chat/sessions/messages/stream`,
body,
options,
"ChatMessage"
);
}
/**
* Get persisted chat messages for a session with date conversion
*/
async getChatMessages(
sessionId: string,
request: Partial<PaginatedRequest> = {}
): Promise<PaginatedResponse<Types.ChatMessage>> {
const paginatedRequest = createPaginatedRequest({
limit: 50, // Higher default for chat messages
...request,
});
const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(
`${this.baseUrl}/chat/sessions/${sessionId}/messages?${params}`,
{
headers: this.defaultHeaders,
}
);
return this.handlePaginatedApiResponseWithConversion<Types.ChatMessage>(
response,
"ChatMessage"
);
}
// ============================
// Password Reset Methods
// ============================
async requestPasswordReset(
request: PasswordResetRequest
): Promise<{ message: string }> {
const response = await fetch(
`${this.baseUrl}/auth/password-reset/request`,
{
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
}
);
return handleApiResponse<{ message: string }>(response);
}
async confirmPasswordReset(
request: PasswordResetConfirm
): Promise<{ message: string }> {
const response = await fetch(
`${this.baseUrl}/auth/password-reset/confirm`,
{
method: "POST",
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)),
}
);
return handleApiResponse<{ message: string }>(response);
}
// ============================
// Password Validation Utilities
// ============================
validatePasswordStrength(password: string): {
isValid: boolean;
issues: string[];
} {
const issues: string[] = [];
if (password.length < 8) {
issues.push("Password must be at least 8 characters long");
}
if (!/[A-Z]/.test(password)) {
issues.push("Password must contain at least one uppercase letter");
}
if (!/[a-z]/.test(password)) {
issues.push("Password must contain at least one lowercase letter");
}
if (!/\d/.test(password)) {
issues.push("Password must contain at least one digit");
}
const specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?/~`\"'\\";
if (!password.split("").some((char) => specialChars.includes(char))) {
issues.push("Password must contain at least one special character");
}
return {
isValid: issues.length === 0,
issues,
};
}
validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
validateUsername(username: string): { isValid: boolean; issues: string[] } {
const issues: string[] = [];
if (username.length < 3) {
issues.push("Username must be at least 3 characters long");
}
if (username.length > 30) {
issues.push("Username must be no more than 30 characters long");
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
issues.push(
"Username can only contain letters, numbers, underscores, and hyphens"
);
}
return {
isValid: issues.length === 0,
issues,
};
}
// ============================
// Error Handling Helper
// ============================
async handleRequest<T>(
requestFn: () => Promise<Response>,
modelType?: string
): Promise<T> {
try {
const response = await requestFn();
return await this.handleApiResponseWithConversion<T>(response, modelType);
} catch (error) {
console.error("API request failed:", error);
throw error;
}
}
// ============================
// Utility Methods
// ============================
setAuthToken(token: string): void {
this.defaultHeaders["Authorization"] = `Bearer ${token}`;
}
clearAuthToken(): void {
delete this.defaultHeaders["Authorization"];
}
getBaseUrl(): string {
return this.baseUrl;
}
}
// ============================
// Request/Response Types
// ============================
export interface CreateCandidateRequest {
email: string;
username: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
}
export interface CreateEmployerRequest {
email: string;
username: string;
password: string;
companyName: string;
industry: string;
companySize: string;
companyDescription: string;
websiteUrl?: string;
phone?: string;
}
export interface EmailVerificationRequest {
token: string;
}
export interface ResendVerificationRequest {
email: string;
}
export interface MFARequest {
email: string;
password: string;
deviceId: string;
deviceName: string;
}
export interface RegistrationResponse {
message: string;
email: string;
verificationRequired: boolean;
}
export interface EmailVerificationResponse {
message: string;
accountActivated: boolean;
userType: string;
}
export interface TrustedDevice {
deviceId: string;
deviceName: string;
browser: string;
browserVersion: string;
os: string;
osVersion: string;
addedAt: string;
lastUsed: string;
ipAddress: string;
}
// ============================
// Additional Types
// ============================
export interface SecurityEvent {
timestamp: string;
userId: string;
eventType:
| "login"
| "logout"
| "mfa_request"
| "mfa_verify"
| "password_change"
| "email_verify"
| "device_add"
| "device_remove";
details: {
ipAddress?: string;
deviceName?: string;
success?: boolean;
failureReason?: string;
[key: string]: any;
};
}
export interface PendingVerification {
id: string;
email: string;
userType: "candidate" | "employer";
createdAt: string;
expiresAt: string;
attempts: number;
}
export { ApiClient, TOKEN_STORAGE };
export type { StreamingOptions, StreamingResponse };