1341 lines
40 KiB
TypeScript
1341 lines
40 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
|
|
} from 'types/conversion';
|
|
|
|
// Import generated date conversion functions
|
|
import {
|
|
// convertCandidateFromApi,
|
|
// convertEmployerFromApi,
|
|
// convertJobFromApi,
|
|
// convertJobApplicationFromApi,
|
|
// convertChatSessionFromApi,
|
|
convertChatMessageFromApi,
|
|
convertFromApi,
|
|
convertArrayFromApi
|
|
} from 'types/types';
|
|
|
|
// ============================
|
|
// Streaming Types and Interfaces
|
|
// ============================
|
|
|
|
interface StreamingOptions {
|
|
onStatus?: (status: Types.ChatMessageStatus) => void;
|
|
onMessage?: (message: Types.ChatMessage) => void;
|
|
onStreaming?: (chunk: Types.ChatMessageStreaming) => void;
|
|
onComplete?: () => void;
|
|
onError?: (error: string | Types.ChatMessageError) => void;
|
|
onWarn?: (warning: string) => void;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
interface DeleteCandidateResponse {
|
|
success: boolean;
|
|
message: string;
|
|
}
|
|
|
|
interface StreamingResponse {
|
|
messageId: string;
|
|
cancel: () => void;
|
|
promise: Promise<Types.ChatMessage[]>;
|
|
}
|
|
|
|
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: number = 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: number = 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: boolean = 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<DeleteCandidateResponse> {
|
|
const response = await fetch(`${this.baseUrl}/candidates/${id}`, {
|
|
method: 'DELETE',
|
|
headers: this.defaultHeaders,
|
|
body: JSON.stringify({ id })
|
|
});
|
|
|
|
return handleApiResponse<DeleteCandidateResponse>(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 createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
|
|
const response = await fetch(`${this.baseUrl}/jobs`, {
|
|
method: 'POST',
|
|
headers: this.defaultHeaders,
|
|
body: JSON.stringify(formatApiRequest(job))
|
|
});
|
|
|
|
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
|
}
|
|
|
|
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 === 'candidate_chat');
|
|
if (!session) {
|
|
session = await this.createCandidateChatSession(
|
|
candidate.username,
|
|
context_type,
|
|
title
|
|
);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/****
|
|
* Document CRUD API
|
|
*/
|
|
async uploadCandidateDocument(file: File, includeInRag: boolean = true): Promise<Types.Document> {
|
|
const formData = new FormData()
|
|
formData.append('file', file);
|
|
formData.append('filename', file.name);
|
|
formData.append('include_in_rag', includeInRag.toString());
|
|
|
|
const response = await fetch(`${this.baseUrl}/candidates/documents/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<Types.Document>(response);
|
|
|
|
return result;
|
|
}
|
|
|
|
async candidateMatchForRequirement(candidate_id: string, requirement: string) : Promise<Types.SkillMatch> {
|
|
const response = await fetch(`${this.baseUrl}/candidates/${candidate_id}/skill-match`, {
|
|
method: 'POST',
|
|
headers: this.defaultHeaders,
|
|
body: JSON.stringify(requirement)
|
|
});
|
|
|
|
const result = await handleApiResponse<Types.SkillMatch>(response);
|
|
|
|
return result;
|
|
}
|
|
|
|
async updateCandidateDocument(document: Types.Document) : Promise<Types.Document> {
|
|
const request : Types.DocumentUpdateRequest = {
|
|
filename: document.filename,
|
|
includeInRAG: document.includeInRAG
|
|
}
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Send message with streaming response support and date conversion
|
|
*/
|
|
sendMessageStream(
|
|
chatMessage: Types.ChatMessageUser,
|
|
options: StreamingOptions = {}
|
|
): StreamingResponse {
|
|
const abortController = new AbortController();
|
|
const signal = options.signal || abortController.signal;
|
|
|
|
let messageId = '';
|
|
|
|
const promise = new Promise<Types.ChatMessage[]>(async (resolve, reject) => {
|
|
try {
|
|
const request = formatApiRequest(chatMessage);
|
|
const response = await fetch(`${this.baseUrl}/chat/sessions/${chatMessage.sessionId}/messages/stream`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...this.defaultHeaders,
|
|
'Accept': 'text/event-stream',
|
|
'Cache-Control': 'no-cache'
|
|
},
|
|
body: JSON.stringify(request),
|
|
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;
|
|
const incomingMessageList: Types.ChatMessage[] = [];
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
|
|
if (done) {
|
|
// Stream ended naturally - create final message
|
|
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);
|
|
|
|
console.log(incoming.status, incoming);
|
|
|
|
// Handle different status types
|
|
switch (incoming.status) {
|
|
case 'streaming':
|
|
console.log(incoming.status, incoming);
|
|
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 = streamingMessage.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 = Types.convertChatMessageFromApi(incoming);
|
|
incomingMessageList.push(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(incomingMessageList);
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
options.onComplete?.();
|
|
reject(new Error('Request was aborted'));
|
|
} else {
|
|
options.onError?.((error as Error).message);
|
|
options.onComplete?.();
|
|
reject(error);
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
messageId,
|
|
cancel: () => abortController.abort(),
|
|
promise
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
export type { StreamingOptions, StreamingResponse } |