import { appendClientIdHeader, buildTrackedPageUrl, getOrCreateClientId } from '../../app/main/clientIdentity'; import { getRegisteredAccessToken } from '../../app/main/tokenAccess'; import type { AppPageDescriptor } from '../../store/appStore/types'; import type { VisitHistory, VisitorClient } from './types'; export type VisitorClientSearchFilters = { search?: string; clientId?: string; nickname?: string; path?: string; visitedFrom?: string; visitedTo?: string; }; class HistoryApiError extends Error { status: number; constructor(message: string, status: number) { super(message); this.name = 'HistoryApiError'; this.status = status; } } function resolveHistoryApiBaseUrl() { if (import.meta.env.VITE_WORK_SERVER_URL) { return import.meta.env.VITE_WORK_SERVER_URL; } return '/api'; } function resolveHistoryFallbackBaseUrl() { if (typeof window === 'undefined') { return null; } const hostname = window.location.hostname; const isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0'; if (!isLocalWorkServerHost) { return null; } const fallbackUrl = new URL(window.location.origin); fallbackUrl.port = '3100'; fallbackUrl.pathname = '/api'; fallbackUrl.search = ''; fallbackUrl.hash = ''; return fallbackUrl.toString().replace(/\/+$/, ''); } const HISTORY_API_BASE_URL = resolveHistoryApiBaseUrl(); const HISTORY_API_FALLBACK_BASE_URL = !import.meta.env.VITE_WORK_SERVER_URL && HISTORY_API_BASE_URL === '/api' ? resolveHistoryFallbackBaseUrl() : null; async function requestOnce(baseUrl: string, path: string, init?: RequestInit) { const headers = appendClientIdHeader(init?.headers); const hasBody = init?.body !== undefined && init.body !== null; const method = init?.method?.toUpperCase() ?? 'GET'; const controller = new AbortController(); const timeoutId = globalThis.setTimeout(() => controller.abort(), 8000); if (hasBody && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } let response: Response; try { response = await fetch(`${baseUrl}${path}`, { ...init, headers, signal: controller.signal, cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined), }); } catch (error) { globalThis.clearTimeout(timeoutId); if (error instanceof DOMException && error.name === 'AbortError') { throw new HistoryApiError('서버 응답이 지연됩니다.', 408); } throw error; } globalThis.clearTimeout(timeoutId); if (!response.ok) { const text = await response.text(); try { const payload = JSON.parse(text) as { message?: string }; throw new HistoryApiError(payload.message || '방문 이력 요청에 실패했습니다.', response.status); } catch { throw new HistoryApiError(text || '방문 이력 요청에 실패했습니다.', response.status); } } return response.json() as Promise; } async function request(path: string, init?: RequestInit): Promise { try { return await requestOnce(HISTORY_API_BASE_URL, path, init); } catch (error) { const shouldRetryWithFallback = HISTORY_API_FALLBACK_BASE_URL && HISTORY_API_FALLBACK_BASE_URL !== HISTORY_API_BASE_URL && (error instanceof HistoryApiError ? error.status === 404 || error.status === 408 || error.status === 502 : error instanceof Error && /404|Failed to fetch|NetworkError/i.test(error.message)); if (!shouldRetryWithFallback) { throw error; } return requestOnce(HISTORY_API_FALLBACK_BASE_URL, path, init); } } function buildAdminHeaders() { const headers = appendClientIdHeader(); const token = getRegisteredAccessToken(); if (token && !headers.has('X-Access-Token')) { headers.set('X-Access-Token', token); } return headers; } export async function reportVisitorPageView(page: AppPageDescriptor) { if (typeof window === 'undefined') { return; } const clientId = getOrCreateClientId(); if (!clientId) { return; } try { await request('/history/track', { method: 'POST', body: JSON.stringify({ clientId, url: buildTrackedPageUrl(page), eventType: 'page_view', userAgent: window.navigator.userAgent, }), }); } catch { // 방문 이력 적재 실패는 사용자 흐름을 막지 않습니다. } } export async function fetchVisitorClients(filters: VisitorClientSearchFilters = {}) { const query = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { const normalizedValue = value?.trim(); if (normalizedValue) { query.set(key, normalizedValue); } }); const suffix = query.toString() ? `?${query.toString()}` : ''; const response = await request<{ ok: boolean; items: VisitorClient[] }>(`/history/visitors${suffix}`, { headers: buildAdminHeaders(), }); return response.items; } export async function fetchVisitorDetail(clientId: string) { const response = await request<{ ok: boolean; client: VisitorClient; visits: VisitHistory[] }>( `/history/visitors/${encodeURIComponent(clientId)}`, { headers: buildAdminHeaders(), }, ); return response; } export async function updateVisitorNickname(clientId: string, nickname: string) { const response = await request<{ ok: boolean; client: VisitorClient }>( `/history/visitors/${encodeURIComponent(clientId)}/nickname`, { method: 'PATCH', headers: buildAdminHeaders(), body: JSON.stringify({ nickname, }), }, ); return response.client; }