backstory/frontend/src/services/api-client.ts
James Ketrenos 179bef1564 Anthropic backend working
Add regenerate skill assessment
2025-07-11 13:24:47 -07:00

1866 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 getJobAnalysis(job: Types.Job, candidate: Types.Candidate): Promise<Types.JobAnalysis> {
const data: Types.JobAnalysis = {
jobId: job.id || '',
candidateId: candidate.id || '',
skills: [],
};
const request = formatApiRequest(data);
const response = await fetch(`${this.baseUrl}/candidates/job-analysis`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(request),
});
return this.handleApiResponseWithConversion<Types.JobAnalysis>(response, 'JobAnalysis');
}
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);
}
regenerateJob(
job: Types.Job,
streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>
): StreamingResponse<Types.JobRequirementsMessage> {
const body = JSON.stringify(formatApiRequest(job));
return this.streamify<Types.JobRequirementsMessage>(
'/jobs/regenerate',
body,
streamingOptions,
'Job'
);
}
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(
resume: Types.Resume,
streamingOptions?: StreamingOptions<Types.ResumeMessage>
): StreamingResponse<Types.ResumeMessage> {
const body = JSON.stringify(formatApiRequest(resume));
return this.streamify<Types.ResumeMessage>(`/resumes`, 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(resume: Types.Resume): Promise<Types.Resume> {
const body = JSON.stringify(formatApiRequest(resume));
const response = await fetch(`${this.baseUrl}/resumes`, {
method: 'PATCH',
headers: this.defaultHeaders,
body: body,
});
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,
regenerate: boolean = false,
streamingOptions?: StreamingOptions<Types.ChatMessageSkillAssessment>
): StreamingResponse<Types.ChatMessageSkillAssessment> {
const body = JSON.stringify({ skill: requirement, regenerate });
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 processStream = async (): Promise<T> => {
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 {
let exit = false;
while (!exit) {
const { done, value } = await reader.read();
if (done) {
// Stream ended naturally
exit = true;
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>(parseApiResponse<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);
}
}
}
}
} finally {
reader.releaseLock();
}
options.onComplete?.();
return finalMessage as T;
} catch (error) {
if (signal.aborted) {
options.onComplete?.();
throw new Error('Request was aborted');
} else {
console.error(error);
options.onError?.({
sessionId: '',
status: 'error',
type: 'text',
content: (error as Error).message,
});
options.onComplete?.();
throw error;
}
}
};
return {
messageId,
cancel: () => abortController.abort(),
promise: processStream(),
};
}
/**
* 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 };