backstory/frontend/src/services/api-client.ts

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 }