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