import type { AuthResponse, CandidateMatch, CreateJobInput, CreateParticipantInput, CreateQuestionInput, CreateScheduledInterviewInput, Interview, InterviewEvent, Invitation, Job, JobStatus, Participant, ParticipantMeeting, Question, ScheduledInterview, User, InterviewNote, } from '../types' const TOKEN_KEY = 'go-guess-token' const USER_KEY = 'go-guess-user' export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired' export class ApiError extends Error { constructor( message: string, public status: number, public details?: unknown, ) { super(message) } } export const authStorage = { token: () => sessionStorage.getItem(TOKEN_KEY), user: () => { const value = sessionStorage.getItem(USER_KEY) return value ? (JSON.parse(value) as AuthResponse['user']) : null }, save: (auth: AuthResponse) => { sessionStorage.setItem(TOKEN_KEY, auth.token) sessionStorage.setItem(USER_KEY, JSON.stringify(auth.user)) }, clear: () => { sessionStorage.removeItem(TOKEN_KEY) sessionStorage.removeItem(USER_KEY) }, expiresAt: () => { try { const payload = sessionStorage.getItem(TOKEN_KEY)?.split('.')[1] if (!payload) return null const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') const claims = JSON.parse(atob(padded)) as { exp?: unknown } return typeof claims.exp === 'number' ? claims.exp * 1000 : null } catch { return null } }, } export function invalidateSession() { authStorage.clear() window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT)) } async function request( path: string, options: RequestInit = {}, protectedRequest = true, ): Promise { const token = authStorage.token() const response = await fetch(`/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }, }) if (!response.ok) { let details: unknown let message = `Request failed with status ${response.status}` try { details = await response.json() if ( typeof details === 'object' && details !== null && 'error' in details && typeof details.error === 'string' ) { message = details.error } } catch { details = undefined } if (response.status === 401 && protectedRequest) invalidateSession() throw new ApiError(message, response.status, details) } if (response.status === 204) return undefined as T return response.json() as Promise } async function requestBlob(path: string): Promise { const token = authStorage.token() const response = await fetch(`/api${path}`, { headers: { Accept: '*/*', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }) if (!response.ok) { if (response.status === 401) invalidateSession() throw new ApiError(`Request failed with status ${response.status}`, response.status) } return response.blob() } async function subscribeToInterviewEvents( path: string, onEvent: (event: InterviewEvent) => void, signal: AbortSignal, protectedRequest: boolean, onConnectionError?: (error: unknown) => void, ) { while (!signal.aborted) { try { const token = authStorage.token() const response = await fetch(`/api${path}`, { headers: { Accept: 'text/event-stream', ...(protectedRequest && token ? { Authorization: 'Bearer ' + token } : {}), }, signal, }) if (!response.ok || !response.body) { if (response.status === 401 && protectedRequest) invalidateSession() throw new ApiError(`Live updates failed with status ${response.status}`, response.status) } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (!signal.aborted) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n') let boundary = buffer.indexOf('\n\n') while (boundary >= 0) { const message = buffer.slice(0, boundary) buffer = buffer.slice(boundary + 2) const data = message .split('\n') .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trimStart()) .join('\n') if (data) onEvent(JSON.parse(data) as InterviewEvent) boundary = buffer.indexOf('\n\n') } } } catch (error) { if (signal.aborted) return if (error instanceof ApiError && error.status >= 400 && error.status < 500) throw error onConnectionError?.(error) } if (!signal.aborted) { await new Promise((resolve) => window.setTimeout(resolve, 1000)) } } } export const api = { login: (email: string, password: string) => request( '/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }, false, ), jobs: { list: () => request('/jobs'), get: (id: number) => request(`/jobs/${id}`), create: (input: CreateJobInput) => request('/jobs', { method: 'POST', body: JSON.stringify(input) }), update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) => request(`/jobs/${jobId}`, { method: 'PATCH', body: JSON.stringify(input), }), attachQuestion: (jobId: number, questionId: number) => request(`/jobs/${jobId}/questions/${questionId}`, { method: 'POST', }), detachQuestion: (jobId: number, questionId: number) => request(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }), candidates: async (jobId: number) => { const matches = await request(`/jobs/${jobId}/candidates`) return matches.map((match) => ({ ...match, matchedTraits: match.matchedTraits ?? [], missingTraits: match.missingTraits ?? [], })) }, invitations: (jobId: number) => request(`/jobs/${jobId}/invitations`), invitation: (jobId: number, invitationId: number) => request(`/jobs/${jobId}/invitations/${invitationId}`), invite: (jobId: number, participantId: number) => request(`/jobs/${jobId}/invitations`, { method: 'POST', body: JSON.stringify({ participantId }), }), setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) => request(`/jobs/${jobId}/invitations/${invitationId}/outcome`, { method: 'PATCH', body: JSON.stringify({ outcome }), }), interviews: (jobId: number) => request(`/jobs/${jobId}/interviews`), scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) => request(`/jobs/${jobId}/interviews`, { method: 'POST', body: JSON.stringify(input), }), }, questions: { list: (search = '') => request(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`), create: (input: CreateQuestionInput) => request('/questions', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: CreateQuestionInput) => request(`/questions/${id}`, { method: 'PUT', body: JSON.stringify(input), }), setDeprecated: (id: number, deprecated: boolean) => request(`/questions/${id}`, { method: 'PATCH', body: JSON.stringify({ deprecated }), }), }, interviews: { get: (token: string) => request(`/interviews/${token}`, {}, false), accept: (token: string) => request(`/interviews/${token}/accept`, { method: 'POST' }, false), answer: (token: string, questionId: number, answer: string) => request( `/interviews/${token}/answers/${questionId}`, { method: 'PUT', body: JSON.stringify({ answer }) }, false, ), finish: (token: string) => request(`/interviews/${token}/finish`, { method: 'POST' }, false), }, participants: { list: () => request('/participants'), get: (id: number) => request(`/participants/${id}`), photo: (id: number) => requestBlob(`/participants/${id}/photo`), cv: (id: number) => requestBlob(`/participants/${id}/cv`), create: (input: CreateParticipantInput) => { const form = new FormData() form.append('firstName', input.firstName) form.append('lastName', input.lastName) form.append('birthday', input.birthday ?? '') form.append('email', input.email) form.append('contactInfo', input.contactInfo) if (input.photo) form.append('photo', input.photo) if (input.cv) form.append('cv', input.cv) return request('/participants', { method: 'POST', body: form }) }, }, users: { list: () => request('/users'), create: (input: { email: string; password: string; displayName: string }) => request('/users', { method: 'POST', body: JSON.stringify(input) }), }, inbox: { list: () => request('/inbox'), respond: (interviewId: number, status: 'accepted' | 'declined') => request(`/inbox/${interviewId}`, { method: 'PATCH', body: JSON.stringify({ status }), }), }, calendar: () => request('/calendar'), scheduledInterviews: { get: (id: number) => request(`/scheduled-interviews/${id}`), setStatus: (id: number, status: string) => request(`/scheduled-interviews/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status }), }), updateDocument: (id: number, sharedDocument: string) => request(`/scheduled-interviews/${id}/document`, { method: 'PATCH', body: JSON.stringify({ sharedDocument }), }), notes: (id: number) => request(`/scheduled-interviews/${id}/notes`), addNote: (id: number, body: string) => request(`/scheduled-interviews/${id}/notes`, { method: 'POST', body: JSON.stringify({ body }), }), subscribe: ( id: number, onEvent: (event: InterviewEvent) => void, signal: AbortSignal, onConnectionError?: (error: unknown) => void, ) => subscribeToInterviewEvents( `/scheduled-interviews/${id}/events`, onEvent, signal, true, onConnectionError, ), }, participantMeetings: { get: (token: string) => request(`/participant-meetings/${token}`, {}, false), subscribe: ( token: string, onEvent: (event: InterviewEvent) => void, signal: AbortSignal, onConnectionError?: (error: unknown) => void, ) => subscribeToInterviewEvents( `/participant-meetings/${token}/events`, onEvent, signal, false, onConnectionError, ), }, }