const API_ORIGIN_PROXY_PREFIX = '/idp-api'; const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, ''); const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, ''); const MOBILE_API_URL_KEY = 'lendry_mobile_api_url'; declare global { interface Window { __LENDRY_DIRECT_API_URL__?: string; } } function resolveDirectApiOverride(): string | null { if (typeof window === 'undefined') return null; const fromWindow = window.__LENDRY_DIRECT_API_URL__?.trim(); if (fromWindow) return fromWindow.replace(/\/$/, ''); const fromStorage = window.localStorage.getItem(MOBILE_API_URL_KEY)?.trim(); if (fromStorage) return fromStorage.replace(/\/$/, ''); return null; } function getServerApiUrl(): string { return (process.env.INTERNAL_API_URL ?? configuredApiUrl).replace(/\/$/, ''); } /** Same-origin /idp-api только если PUBLIC API URL явно задан как прокси-путь на фронтенде. */ function usesSameOriginApiProxy(): boolean { if (configuredApiUrl.startsWith('/')) { return true; } return /\/idp-api\/?$/i.test(configuredApiUrl) || configuredApiUrl.includes('/idp-api/'); } function usesSameOriginWsProxy(): boolean { if (configuredWsUrl.startsWith('/')) { return true; } return configuredWsUrl.includes('/idp-api/'); } function isLocalDevApiUrl(url: string): boolean { return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(url); } function resolveBrowserApiBaseUrl(): string { const directApi = resolveDirectApiOverride(); if (directApi) return directApi; // SSR: внутренний URL Docker-сети (api-gateway), не публичный домен. if (typeof window === 'undefined') { return getServerApiUrl(); } if (configuredApiUrl.startsWith('/')) { return configuredApiUrl.replace(/\/+$/, '') || API_ORIGIN_PROXY_PREFIX; } if (usesSameOriginApiProxy()) { return API_ORIGIN_PROXY_PREFIX; } // Split-domain (SSO на sso.*, API на api.*): браузер SPA ходит same-origin через // /idp-api на домене SSO. PUBLIC_API_URL=https://api.* остаётся для OAuth issuer и // внешних клиентов — cross-origin fetch из SPA не нужен и ломается (CORS/502). if (!isLocalDevApiUrl(configuredApiUrl)) { return API_ORIGIN_PROXY_PREFIX; } return configuredApiUrl; } function resolveBrowserWsBaseUrl(): string { if (typeof window === 'undefined') { return configuredWsUrl; } if (usesSameOriginWsProxy()) { const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; } if (!/^wss?:\/\/(localhost|127\.0\.0\.1)/i.test(configuredWsUrl)) { const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; } return configuredWsUrl; } export function getApiUrl(): string { return resolveBrowserApiBaseUrl(); } export function getWsUrl(): string { return resolveBrowserWsBaseUrl(); } /** @deprecated Используйте getApiUrl() — в браузере URL зависит от режима (split-domain или /idp-api) */ export const API_URL = configuredApiUrl; export interface PublicUser { id: string; email?: string | null; phone?: string | null; backupEmail?: string | null; backupPhone?: string | null; displayName: string; username?: string | null; avatarUrl?: string | null; hasAvatar?: boolean; isSuperAdmin: boolean; status: string; roles?: string[]; permissions?: string[]; canAccessAdmin?: boolean; canManageRoles?: boolean; canViewOAuth?: boolean; canManageOAuth?: boolean; canManageAllOAuth?: boolean; canManageUsers?: boolean; canManageAllUsers?: boolean; canManageSettings?: boolean; canViewUsers?: boolean; canViewUserDocuments?: boolean; hasPassword?: boolean; isVerified?: boolean; verificationIcon?: string | null; canVerifyUsers?: boolean; canManageBots?: boolean; canModerateChats?: boolean; } export interface AdminUser { id: string; email?: string | null; phone?: string | null; displayName: string; username?: string | null; isSuperAdmin: boolean; status: string; createdAt: string; roles: string[]; directPermissions?: string[]; isVerified?: boolean; verificationIcon?: string | null; isBot?: boolean; linkedBotUsername?: string | null; isSystemBot?: boolean; } export interface AdminRole { id: string; slug: string; name: string; description?: string; isSystem?: boolean; isDefault?: boolean; permissions: { id: string; slug: string; name: string; description?: string }[]; } export interface AdminPermission { id: string; slug: string; name: string; description?: string; } export interface OAuthScope { id: string; slug: string; name: string; description?: string; } export interface OAuthConsentScopeInfo { slug: string; name: string; description?: string; } export interface OAuthConsentCheckResponse { granted: boolean; client?: { clientId: string; name: string; }; requestedScopes?: OAuthConsentScopeInfo[]; } export interface OAuthUserConsent { id: string; clientId: string; clientName: string; scopes: OAuthConsentScopeInfo[]; grantedAt: string; updatedAt: string; } export interface OAuthClient { id: string; name: string; clientId: string; redirectUris: string[]; scopes: string[]; isActive: boolean; type: string; clientSecret?: string; createdById?: string | null; createdByDisplayName?: string | null; canManage?: boolean; } export interface AuthTokens { accessToken: string; refreshToken: string; expiresAt: string; pinVerified: boolean; user: PublicUser; sessionId: string; requiresTotp?: boolean; totpChallengeToken?: string; } export interface UserProfileResponse { birthDate?: string | null; email?: string | null; phone?: string | null; backupEmail?: string | null; backupPhone?: string | null; displayName?: string; firstName?: string | null; lastName?: string | null; } export interface QrLoginSession { sessionId: string; status: 'PENDING' | 'APPROVED' | 'EXPIRED'; expiresAt: string; qrPayload?: string; claimed?: boolean; claimedDeviceName?: string; auth?: AuthTokens & { user?: PublicUser }; } export async function createQrLoginSession(deviceName?: string) { const { buildAuthDevicePayload } = await import('@/lib/device-client'); const device = buildAuthDevicePayload(); return apiFetch('/auth/advanced/qr/session', { method: 'POST', body: JSON.stringify({ ...device, deviceName: deviceName?.trim() || device.deviceName }) }); } export async function pollQrLoginSession(sessionId: string) { return apiFetch(`/auth/advanced/qr/session/${sessionId}`); } export async function createDeviceLinkQrSession(userId: string, token: string) { return apiFetch(`/security/users/${userId}/device-link/session`, { method: 'POST' }, token); } export async function claimQrLoginSession(sessionId: string) { const { buildAuthDevicePayload } = await import('@/lib/device-client'); const device = buildAuthDevicePayload(); return apiFetch(`/auth/advanced/qr/session/${sessionId}/claim`, { method: 'POST', body: JSON.stringify(device) }); } export async function approveQrLoginSession(sessionId: string, token: string) { return apiFetch(`/auth/advanced/qr/session/${sessionId}/approve`, { method: 'POST' }, token); } export interface TotpSetupResponse { secret: string; otpauthUrl: string; } export interface TotpStatusResponse { isEnabled: boolean; } export interface PresignedUploadResponse { uploadUrl: string; storageKey: string; expiresAt?: string; uploadToken?: string; } export async function uploadMediaObject( presigned: PresignedUploadResponse, file: Blob, contentType: string ) { if (presigned.uploadToken) { const apiBase = getApiUrl().replace(/\/$/, ''); const formData = new FormData(); formData.append('file', file, file instanceof File ? file.name : 'upload.bin'); const response = await platformFetch(`${apiBase}/media/upload`, { method: 'POST', headers: { 'X-Upload-Token': presigned.uploadToken }, body: formData }); if (!response.ok) { throw new Error('Не удалось загрузить файл'); } return; } const response = await platformFetch(presigned.uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: file }); if (!response.ok) { throw new Error('Не удалось загрузить файл'); } } export interface FamilyInviteCandidate { id: string; displayName: string; email?: string | null; phone?: string | null; username?: string | null; hasAvatar?: boolean; isVerified?: boolean; verificationIcon?: string | null; isBot?: boolean; botUsername?: string; botId?: string; ownerDisplayName?: string; } export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) { return apiFetch<{ users?: FamilyInviteCandidate[] }>( `/family/groups/${groupId}/invite-search?q=${encodeURIComponent(query)}`, {}, token ); } export interface PasswordlessAuthResponse { requiresPassword: boolean; requiresTotp?: boolean; totpChallengeToken?: string; tempAuthToken?: string; auth?: AuthTokens; } export interface OtpChannel { channel: 'email' | 'phone' | 'backupEmail' | 'backupPhone' | string; masked: string; } export interface LoginMethod { kind: 'password' | 'otp'; channel: string; masked: string; } export interface IdentifyResponse { exists: boolean; hasPassword: boolean; isPinEnabled: boolean; isTotpEnabled?: boolean; otpChannels?: OtpChannel[]; otpBackupChannels?: OtpChannel[]; methods?: LoginMethod[]; } export interface BeginTotpLoginResponse { totpChallengeToken: string; } export interface OtpSendResponse { sent: boolean; expiresAt: string; maskedTarget: string; resendAvailableAt?: string; } export interface PinVerificationResponse { accessToken: string; pinVerified: boolean; } export interface RefreshSessionResponse { requiresPin: boolean; accessToken?: string; sessionId?: string; pinVerified: boolean; user?: PublicUser; } export interface AuthSessionResponse { requiresPin: boolean; sessionId?: string | null; pinVerified: boolean; user: PublicUser; } export const AUTH_TOKEN_KEY = 'lendry_access_token'; export const AUTH_REFRESH_KEY = 'lendry_refresh_token'; export const AUTH_SESSION_KEY = 'lendry_session_id'; export const AUTH_USER_CACHE_KEY = 'lendry_cached_user'; /** Актуальный access token из localStorage (не полагается на React state). */ export function getAccessToken(): string | null { if (typeof window === 'undefined') return null; const value = window.localStorage.getItem(AUTH_TOKEN_KEY)?.trim(); return value || null; } function resolveAccessToken(explicit?: string | null): string | null { const trimmed = explicit?.trim(); if (trimmed) return trimmed; return getAccessToken(); } export class ApiError extends Error { status: number; code?: string; sessionId?: string; constructor(message: string, status: number, code?: string, sessionId?: string) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; this.sessionId = sessionId; } } function mapFetchError(error: unknown): ApiError { if (error instanceof TypeError) { const message = error.message.toLowerCase(); if (message.includes('failed to fetch') || message.includes('networkerror')) { return new ApiError( 'Нет связи с сервером. При самоподписанном HTTPS примите сертификат сайта или выполните ./install.sh --fix-proxy на сервере.', 0, 'NETWORK_ERROR' ); } } if (error instanceof ApiError) { return error; } return new ApiError(error instanceof Error ? error.message : 'Не удалось выполнить запрос', 0, 'UNKNOWN'); } type PinRequiredHandler = (sessionId: string) => void; export function setPinRequiredHandler(handler: PinRequiredHandler | null) { pinRequiredHandler = handler; if (!handler) { pinNotificationSent = false; } } interface ErrorBody { message?: string | string[]; error?: string; code?: string; sessionId?: string; } async function parseErrorResponse(response: Response): Promise { try { const body = (await response.clone().json()) as ErrorBody; const message = Array.isArray(body.message) ? body.message.join('\n') : (body.message ?? body.error ?? 'Не удалось выполнить запрос'); const pinRequired = body.code === 'PIN_REQUIRED' || (response.status === 403 && typeof message === 'string' && message.toLowerCase().includes('pin')); return new ApiError(message, response.status, pinRequired ? 'PIN_REQUIRED' : body.code, body.sessionId); } catch { return new ApiError('Не удалось выполнить запрос', response.status); } } export function isPinRequiredError(error: unknown): error is ApiError { return error instanceof ApiError && error.code === 'PIN_REQUIRED'; } export function isGatewayUnavailableError(error: unknown): boolean { return ( error instanceof ApiError && (error.code === 'GATEWAY_UNAVAILABLE' || [502, 503, 504].includes(error.status)) ); } export function getApiErrorMessage(error: unknown, fallback: string): string | null { if (isPinRequiredError(error)) return null; if (isGatewayUnavailableError(error)) return null; if (error instanceof ApiError && error.code === 'NO_TOKEN') return null; if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) { return null; } return error instanceof Error ? error.message : fallback; } let pinRequiredHandler: PinRequiredHandler | null = null; let pinNotificationSent = false; export function resetPinRequiredNotification() { pinNotificationSent = false; } let customFetchImpl: typeof fetch | null = null; /** Tauri/mobile: native HTTP (bypasses WebView CORS and self-signed TLS). */ export function setCustomFetch(fetchImpl: typeof fetch | null) { customFetchImpl = fetchImpl; } function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise { return (customFetchImpl ?? fetch)(input, init); } function notifyPinRequired(sessionId: string) { if (!pinRequiredHandler || pinNotificationSent) return; pinNotificationSent = true; pinRequiredHandler(sessionId); } export async function fetchAuthSession(token?: string | null): Promise { return apiFetch('/auth/session', {}, token, true, { critical: true }); } export async function approveOAuthAuthorization(params: URLSearchParams, token: string) { const body = { client_id: params.get('client_id') ?? params.get('clientId') ?? undefined, redirect_uri: params.get('redirect_uri') ?? params.get('redirectUri') ?? undefined, scope: params.get('scope') ?? 'openid profile', state: params.get('state') ?? undefined, nonce: params.get('nonce') ?? undefined }; return apiFetch<{ redirectUrl?: string }>('/oauth/consent/approve', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, token, true, { critical: true }); } export async function checkOAuthConsent(params: URLSearchParams, token: string) { const query = new URLSearchParams(); const clientId = params.get('client_id') ?? params.get('clientId'); const scope = params.get('scope') ?? 'openid profile'; if (clientId) query.set('client_id', clientId); query.set('scope', scope); return apiFetch(`/oauth/consent/check?${query.toString()}`, {}, token, true, { critical: true }); } export async function fetchOAuthClientPublicInfo(clientId: string) { return apiFetch<{ clientId: string; name: string }>( `/oauth/clients/${encodeURIComponent(clientId)}/public`, {}, null, true, { silent: true } ); } export async function fetchUserOAuthConsents(userId: string, token: string) { return apiFetch<{ consents?: OAuthUserConsent[] }>(`/oauth/consents/users/${userId}`, {}, token); } export async function revokeOAuthConsent(userId: string, consentId: string, token: string) { return apiFetch(`/oauth/consents/users/${userId}/${consentId}`, { method: 'DELETE' }, token); } export async function refreshAuthSession(): Promise { if (typeof window === 'undefined') { throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH'); } const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); const sessionId = window.localStorage.getItem(AUTH_SESSION_KEY); if (!refreshToken) { throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH'); } const response = await fetchWithGatewayRetry(`${getApiUrl()}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined }) }); if (!response.ok) { throw await parseErrorResponse(response); } return response.json() as Promise; } export async function syncFedcmSession(accessToken?: string | null) { const token = resolveAccessToken(accessToken); if (typeof window === 'undefined' || !token) return; try { await apiFetch<{ synced: boolean }>( '/fedcm/session/sync', { method: 'POST', credentials: 'include' }, token, true, { silent: true } ); } catch { // фоновая синхронизация FedCM cookie } } export interface FedcmSessionStatus { active: boolean; requiresPin: boolean; sessionId: string | null; userId: string | null; } export async function fetchFedcmSessionStatus(apiBase: string): Promise { if (typeof window === 'undefined' || !apiBase.trim()) return null; const base = apiBase.replace(/\/+$/, ''); try { const response = await fetch(`${base}/fedcm/session/status`, { method: 'GET', credentials: 'include', cache: 'no-store' }); if (!response.ok) return null; return (await response.json()) as FedcmSessionStatus; } catch { return null; } } let fedcmSyncTimer: ReturnType | null = null; let fedcmSyncInFlight = false; /** Откладывает FedCM sync после OAuth/bootstrap, чтобы не конкурировать с consent/session. */ export function scheduleFedcmSessionSync(accessToken?: string | null, delayMs = 8000) { if (typeof window === 'undefined') return; if (window.location.pathname.startsWith('/auth/oauth/authorize')) return; if (window.location.pathname.startsWith('/auth/login')) return; if (window.location.pathname.startsWith('/auth/register')) return; if (fedcmSyncTimer) { clearTimeout(fedcmSyncTimer); } fedcmSyncTimer = setTimeout(() => { fedcmSyncTimer = null; if (fedcmSyncInFlight) return; fedcmSyncInFlight = true; void syncFedcmSession(accessToken).finally(() => { fedcmSyncInFlight = false; }); }, delayMs); } async function trySilentTokenRefresh(): Promise { try { const refreshed = await refreshAuthSession(); if (refreshed.requiresPin) { notifyPinRequired(refreshed.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return refreshed; } if (refreshed.accessToken) { window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken); if (refreshed.sessionId) { window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId); } if (refreshed.user) { window.dispatchEvent(new CustomEvent('lendry:auth-refreshed', { detail: refreshed })); } } return refreshed; } catch (error) { if (error instanceof ApiError && error.status === 401) { notifyAuthSessionInvalid(); } return null; } } export interface UserDocument { id: string; userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string; } export interface SystemSetting { id: string; key: string; value: string; description?: string | null; isSecret: boolean; } export interface SocialProvider { id: string; providerName: string; clientId: string; isEnabled: boolean; } export interface ActiveDevice { id: string; name: string; type: string; lastIp?: string | null; lastSeenAt: string; trusted: boolean; } export interface AdminInsightsPeriodMeta { retentionDays: number; periodFrom: string; periodTo: string; } export interface SignInEvent { id: string; success: boolean; reason?: string | null; ipAddress?: string | null; userAgent?: string | null; deviceName?: string | null; createdAt: string; } export interface AdminUserActivityItem { id: string; action: string; title: string; detail?: string; entityType?: string; entityId?: string; createdAt: string; } export interface AdminChatRoomSummary { id: string; type: string; name: string; groupId: string; groupName: string; memberCount: number; messageCount: number; members: Array<{ id: string; displayName: string }>; lastMessage?: { id: string; senderName: string; preview: string; createdAt: string; }; joinedAt: string; } export interface AdminChatMessage { id: string; senderId: string; senderName: string; type: string; content?: string | null; preview: string; isEncrypted: boolean; isDeleted: boolean; createdAt: string; editedAt?: string; roomId?: string; roomName?: string; groupName?: string; storageKey?: string; mimeType?: string; metadataJson?: string; } export interface ActiveSession { id: string; userId: string; deviceId?: string | null; deviceName?: string | null; deviceType?: string | null; pinVerified: boolean; status: string; ipAddress?: string | null; userAgent?: string | null; expiresAt: string; createdAt: string; updatedAt: string; } async function parseError(response: Response): Promise { const error = await parseErrorResponse(response); return error.message; } export function buildSystemSettingPayload(setting: Pick) { return { key: setting.key, value: setting.value, description: setting.description ?? undefined, isSecret: setting.isSecret }; } const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]); const GATEWAY_RETRY_ATTEMPTS = 3; const GATEWAY_CRITICAL_RETRY_ATTEMPTS = 4; export interface ApiFetchBehavior { /** Пользовательский сценарий (OAuth, вход) — не блокировать circuit breaker. */ critical?: boolean; /** Опциональный/фоновый запрос — не открывать circuit breaker при ошибке. */ silent?: boolean; } const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800; const GATEWAY_STABLE_MAX_ROUNDS = 8; const API_MAX_CONCURRENT = 6; const API_BURST_MAX_CONCURRENT = 4; const API_BURST_WINDOW_MS = 8000; const API_READY_EVENT = 'idp-api-ready'; const API_NOT_READY_EVENT = 'idp-api-not-ready'; export const AUTH_SESSION_INVALID_EVENT = 'idp-auth-session-invalid'; const GATEWAY_CIRCUIT_COOLDOWN_MS = 8_000; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } let gatewayReadyResolved = false; let gatewayReadyPromise: Promise | null = null; let stableGatewayPromise: Promise | null = null; let gatewayReadyAt = 0; let gatewayCircuitOpenUntil = 0; let activeApiRequests = 0; const apiRequestWaiters: Array<() => void> = []; export function resetApiGatewayWarmup() { gatewayReadyResolved = false; gatewayReadyPromise = null; stableGatewayPromise = null; gatewayCircuitOpenUntil = 0; } /** Сбрасывает circuit breaker после кратковременных 502 — для OAuth и других интерактивных экранов. */ export function resetGatewayCircuit(): void { gatewayCircuitOpenUntil = 0; } export function invalidateApiGatewayReady() { gatewayReadyResolved = false; gatewayReadyAt = 0; stableGatewayPromise = null; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT)); } } function shouldBypassGatewayCircuit(behavior?: ApiFetchBehavior): boolean { return Boolean(behavior?.critical || behavior?.silent); } function openGatewayCircuit(behavior?: ApiFetchBehavior): void { if (shouldBypassGatewayCircuit(behavior)) return; // До стабильного прогрева /health circuit не открываем — иначе каждая перезагрузка ловит ложный 502. if (!gatewayReadyResolved) return; gatewayReadyResolved = false; gatewayReadyAt = 0; stableGatewayPromise = null; gatewayCircuitOpenUntil = Date.now() + GATEWAY_CIRCUIT_COOLDOWN_MS; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT)); } } function assertGatewayCircuitClosed(behavior?: ApiFetchBehavior): void { if (typeof window === 'undefined' || shouldBypassGatewayCircuit(behavior)) return; if (!gatewayReadyAt && !gatewayCircuitOpenUntil) return; if (Date.now() < gatewayCircuitOpenUntil) { throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE'); } gatewayCircuitOpenUntil = 0; } function notifyAuthSessionInvalid(): void { if (typeof window === 'undefined') return; window.dispatchEvent(new CustomEvent(AUTH_SESSION_INVALID_EVENT)); } export function isApiGatewayReady() { return gatewayReadyResolved; } export function markApiGatewayReady() { if (gatewayReadyResolved) return; gatewayReadyResolved = true; gatewayReadyAt = Date.now(); gatewayCircuitOpenUntil = 0; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(API_READY_EVENT)); } } export function subscribeApiReady(listener: () => void): () => void { if (typeof window === 'undefined') return () => undefined; if (gatewayReadyResolved) { listener(); return () => undefined; } window.addEventListener(API_READY_EVENT, listener); return () => window.removeEventListener(API_READY_EVENT, listener); } export function subscribeApiNotReady(listener: () => void): () => void { if (typeof window === 'undefined') return () => undefined; window.addEventListener(API_NOT_READY_EVENT, listener); return () => window.removeEventListener(API_NOT_READY_EVENT, listener); } /** Несколько подряд успешных /health через nginx — защита от «один OK, потом 502». */ export async function waitForStableGateway(stableProbes = 3): Promise { if (typeof window === 'undefined') return; if (gatewayReadyResolved && stableProbes <= 1) return; if (stableGatewayPromise) return stableGatewayPromise; stableGatewayPromise = (async () => { gatewayReadyResolved = false; gatewayReadyAt = 0; let streak = 0; for (let attempt = 0; attempt < GATEWAY_STABLE_MAX_ROUNDS && streak < stableProbes; attempt += 1) { if (await probeGatewayAlive()) { streak += 1; if (streak >= stableProbes) { markApiGatewayReady(); return; } await sleep(GATEWAY_STABLE_PROBE_INTERVAL_MS); } else { streak = 0; await sleep(Math.min(900 + attempt * 300, 2800)); } } throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE'); })().finally(() => { stableGatewayPromise = null; }); return stableGatewayPromise; } async function acquireApiSlot(): Promise { const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS; const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT; while (activeApiRequests >= limit) { await new Promise((resolve) => { apiRequestWaiters.push(resolve); }); } activeApiRequests += 1; } function releaseApiSlot(): void { activeApiRequests = Math.max(0, activeApiRequests - 1); const next = apiRequestWaiters.shift(); if (next) next(); } /** Liveness: nginx → api-gateway HTTP. */ async function probeGatewayAlive(): Promise { try { const response = await platformFetch(`${getApiUrl()}/health`, { method: 'GET', cache: 'no-store', signal: AbortSignal.timeout(4500) }); return response.ok; } catch { return false; } } /** Один общий прогрев: все apiFetch ждут стабильный /health через nginx. */ export async function ensureApiGatewayReady(force = false): Promise { if (typeof window === 'undefined') return; if (gatewayReadyResolved && !force) return; if (gatewayReadyPromise) return gatewayReadyPromise; if (force) { gatewayReadyResolved = false; gatewayReadyAt = 0; stableGatewayPromise = null; gatewayCircuitOpenUntil = 0; } const probes = force ? 2 : 2; gatewayReadyPromise = waitForStableGateway(probes).finally(() => { gatewayReadyPromise = null; }); return gatewayReadyPromise; } function gatewayRetryDelay(attempt: number): number { return 600 + attempt * 500; } /** Повтор при кратковременном 502/503/504 без сброса всего UI в bootstrap. */ async function fetchWithGatewayRetry( url: string, init: RequestInit, behavior?: ApiFetchBehavior ): Promise { const maxAttempts = behavior?.critical ? GATEWAY_CRITICAL_RETRY_ATTEMPTS : GATEWAY_RETRY_ATTEMPTS; let lastNetworkError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { assertGatewayCircuitClosed(behavior); const response = await platformFetch(url, init); if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < maxAttempts) { await sleep(gatewayRetryDelay(attempt)); continue; } if (GATEWAY_RETRY_STATUS.has(response.status)) { openGatewayCircuit(behavior); } return response; } catch (error) { lastNetworkError = error; if (isGatewayUnavailableError(error)) { throw error; } if (attempt + 1 < maxAttempts) { await sleep(gatewayRetryDelay(attempt)); continue; } openGatewayCircuit(behavior); throw mapFetchError(error); } } openGatewayCircuit(behavior); throw mapFetchError(lastNetworkError); } export async function apiFetch( path: string, options: RequestInit = {}, token?: string | null, allowRetry = true, behavior: ApiFetchBehavior = {} ): Promise { if (!gatewayReadyResolved && !behavior.silent) { try { await ensureApiGatewayReady(); } catch { // fetchWithGatewayRetry попробует сам после кратковременного 502 } } await acquireApiSlot(); const resolvedToken = resolveAccessToken(token); const method = (options.method ?? 'GET').toUpperCase(); const hasBody = options.body !== undefined && options.body !== null; const headers: Record = { ...(hasBody || method === 'POST' || method === 'PUT' || method === 'PATCH' ? { 'Content-Type': 'application/json' } : {}), ...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}), ...(behavior.silent ? { 'X-Id-Passive-Activity': '1' } : {}), ...(options.headers as Record | undefined) }; let response: Response; let apiSlotReleased = false; const releaseCurrentApiSlot = () => { if (apiSlotReleased) return; apiSlotReleased = true; releaseApiSlot(); }; try { try { response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { ...options, headers }, behavior); } catch (error) { if (error instanceof ApiError) { throw error; } throw mapFetchError(error); } if (!response.ok) { const error = await parseErrorResponse(response); if (error.code === 'PIN_REQUIRED') { notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); throw error; } if (resolvedToken && error.status === 401 && error.code !== 'TOKEN_EXPIRED') { notifyAuthSessionInvalid(); throw error; } if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { const refreshed = await trySilentTokenRefresh(); if (refreshed?.accessToken) { releaseCurrentApiSlot(); return apiFetch(path, options, refreshed.accessToken, false, behavior); } if (refreshed?.requiresPin) { throw error; } } if (resolvedToken && error.status === 401) { notifyAuthSessionInvalid(); } throw error; } return response.json() as Promise; } finally { releaseCurrentApiSlot(); } } export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client'; export interface MediaAccessResponse { accessUrl: string; expiresAt: string; } export async function fetchDocumentPhotoUrl( userId: string, storageKey: string, token?: string | null, fileName?: string ) { const query = new URLSearchParams({ storageKey }); if (fileName) query.set('fileName', fileName); return apiFetch(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token); } export async function fetchChatMediaUrl(roomId: string, storageKey: string, token?: string | null, fileName?: string) { const query = new URLSearchParams({ storageKey }); if (fileName) query.set('fileName', fileName); return apiFetch(`/media/chat/${roomId}/media/url?${query.toString()}`, {}, token); } export interface AccountDeletionStatus { pending: boolean; deletionRequestedAt?: string; effectiveAt?: string; graceDays: number; } export async function requestAccountDeletion(userId: string, token?: string | null) { return apiFetch( `/profile/users/${userId}/self-delete`, { method: 'POST' }, token ); } export async function cancelAccountDeletion(userId: string, token?: string | null) { return apiFetch<{ userId: string; cancelled: boolean }>( `/profile/users/${userId}/self-delete/cancel`, { method: 'POST' }, token ); } export async function fetchAccountDeletionStatus(userId: string, token?: string | null) { return apiFetch(`/profile/users/${userId}/self-delete/status`, {}, token); } /** @deprecated Используйте requestAccountDeletion — удаление теперь отложенное */ export async function softDeleteProfile(userId: string, token?: string | null) { return requestAccountDeletion(userId, token); } export interface UserAddress { id: string; userId: string; label: 'HOME' | 'WORK' | 'OTHER'; city: string; street: string; house: string; apartment?: string; comment?: string; latitude?: number; longitude?: number; fullAddress?: string; createdAt: string; updatedAt: string; } export async function fetchUserAddresses(userId: string, token?: string | null) { return apiFetch<{ addresses?: UserAddress[] }>(`/addresses/users/${userId}`, {}, token); } export async function upsertUserAddress( userId: string, body: { addressId?: string; label: UserAddress['label']; city: string; street: string; house: string; apartment?: string; comment?: string; latitude?: number; longitude?: number; fullAddress?: string; }, token?: string | null ) { return apiFetch(`/addresses/users/${userId}`, { method: 'POST', body: JSON.stringify(body) }, token); } export async function deleteUserAddress(userId: string, addressId: string, token?: string | null) { return apiFetch<{ count: number }>(`/addresses/users/${userId}/${addressId}`, { method: 'DELETE' }, token); } export interface FamilyMember { id: string; userId: string; role: string; displayName: string; hasAvatar: boolean; isVerified?: boolean; verificationIcon?: string | null; isBot?: boolean; botUsername?: string; } export interface FamilyGroup { id: string; ownerId: string; name: string; hasAvatar: boolean; members?: FamilyMember[]; botFatherAvailable?: boolean; getMyIdBotAvailable?: boolean; } export interface BotChatMessage { id: string; direction: 'in' | 'out'; scope?: 'private' | 'broadcast'; text: string; messageType: string; messageId: number; createdAt: string; replyMarkupJson?: string | null; isPinned?: boolean; pinnedAt?: string | null; } export interface BotChatMeta { botId?: string; botOwnerId?: string; botUsername?: string; botDisplayName?: string; composerWebAppUrl?: string | null; composerMenuButtonJson?: string | null; manageWebAppUrl?: string; } export interface FamilyInvite { id: string; groupId: string; groupName: string; inviterId: string; inviterName: string; inviteeUserId?: string; targetEmail?: string; targetPhone?: string; status: string; expiresAt: string; } export interface AppNotification { id: string; type: string; title: string; message: string; payloadJson?: string; isRead: boolean; createdAt: string; } export interface ChatPollOption { id: string; text: string; voteCount: number; votedByMe: boolean; } export interface ChatMessageReaction { emoji: string; count: number; reactedByMe: boolean; } export interface ChatMessage { id: string; roomId: string; senderId: string; senderName: string; senderHasAvatar: boolean; senderIsVerified?: boolean; senderVerificationIcon?: string | null; type: string; content?: string; isEncrypted?: boolean; replyToId?: string; storageKey?: string; mimeType?: string; metadataJson?: string; createdAt: string; editedAt?: string; isDeleted?: boolean; isPinned?: boolean; pinnedAt?: string; pinnedById?: string; readByAll?: boolean; poll?: { id: string; question: string; allowsMultiple: boolean; isAnonymous: boolean; options: ChatPollOption[]; }; reactions?: ChatMessageReaction[]; } export interface FamilyPresenceMember { userId: string; displayName: string; online: boolean; lastSeenAt?: string; } export interface FamilyPresence { members: FamilyPresenceMember[]; onlineCount: number; } export interface ChatRoomMember { id: string; userId: string; displayName: string; hasAvatar: boolean; isVerified?: boolean; verificationIcon?: string | null; notificationsMuted: boolean; isPinned?: boolean; pinnedAt?: string; familyRole?: string; isChatCreator?: boolean; inFamily?: boolean; } export interface ChatRoom { id: string; groupId: string; type: string; name: string; peerUserId?: string; botUsername?: string; isE2E?: boolean; hasAvatar: boolean; createdById?: string; updatedAt: string; isPinned?: boolean; pinnedAt?: string; peerLeftFamily?: boolean; members: ChatRoomMember[]; lastMessage?: ChatMessage; } export async function fetchFamilyGroups(userId: string, token?: string | null) { const accessToken = resolveAccessToken(token); if (!accessToken) { throw new ApiError('Не авторизован', 401, 'NO_TOKEN'); } return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, accessToken, true, { silent: true }); } export async function fetchFamilyGroup(groupId: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}`, {}, token, true, { silent: true }); } export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token); } export async function deleteFamilyGroup(groupId: string, token?: string | null) { return apiFetch<{ count: number }>(`/family/groups/${groupId}`, { method: 'DELETE' }, token); } export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) { return apiFetch(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); } export async function fetchBotChatMessages(botRef: string, token?: string | null, roomId?: string) { const suffix = roomId ? `?roomId=${encodeURIComponent(roomId)}` : ''; return apiFetch<{ messages?: BotChatMessage[]; botUsername?: string; botDisplayName?: string; botId?: string; botOwnerId?: string; composerWebAppUrl?: string; composerMenuButtonJson?: string; manageWebAppUrl?: string; }>(`/bots/by-username/${encodeURIComponent(botRef)}/messages${suffix}`, {}, token); } export async function sendBotMessage(botRef: string, text: string, token?: string | null, roomId?: string) { return apiFetch<{ updateId?: number; messageId?: number; chatId?: string }>( `/bots/by-username/${encodeURIComponent(botRef)}/messages`, { method: 'POST', body: JSON.stringify({ text, roomId }) }, token ); } export async function submitBotCallback(botRef: string, messageId: number, callbackData: string, token?: string | null) { return apiFetch<{ updateId?: number; callbackQueryId?: string; messageId?: number }>( `/bots/by-username/${encodeURIComponent(botRef)}/callback`, { method: 'POST', body: JSON.stringify({ messageId, callbackData }) }, token ); } export async function fetchFamilyInvites(token?: string | null) { return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token); } export async function respondFamilyInvite(inviteId: string, accept: boolean, token?: string | null) { return apiFetch(`/family/invites/${inviteId}/respond`, { method: 'POST', body: JSON.stringify({ accept }) }, token); } export async function fetchNotifications(token?: string | null, unreadOnly = false) { return apiFetch<{ notifications?: AppNotification[] }>(`/notifications?unreadOnly=${unreadOnly}`, {}, token); } export async function fetchUnreadNotificationCount(token?: string | null) { return apiFetch<{ count: number }>('/notifications/unread-count', {}, token, true, { silent: true }); } export async function markNotificationRead(notificationId: string, token?: string | null) { return apiFetch(`/notifications/${notificationId}/read`, { method: 'PATCH', body: '{}' }, token); } export async function markAllNotificationsRead(token?: string | null) { return apiFetch('/notifications/read-all', { method: 'POST' }, token); } export async function fetchChatRooms(groupId: string, token?: string | null) { return apiFetch<{ rooms?: ChatRoom[] }>(`/chat/groups/${groupId}/rooms`, {}, token); } export async function createChatRoom(groupId: string, name: string, memberUserIds: string[], token?: string | null) { return apiFetch(`/chat/groups/${groupId}/rooms`, { method: 'POST', body: JSON.stringify({ name, memberUserIds }) }, token); } export async function addChatRoomMember(roomId: string, memberUserId: string, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}/members`, { method: 'POST', body: JSON.stringify({ memberUserId }) }, token); } export async function removeChatRoomMember(roomId: string, memberUserId: string, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}/members/${memberUserId}`, { method: 'DELETE' }, token); } export async function removeFamilyMember(memberId: string, token?: string | null) { return apiFetch<{ count: number }>(`/family/members/${memberId}`, { method: 'DELETE' }, token); } export async function leaveFamilyGroup(groupId: string, token?: string | null, newOwnerUserId?: string) { return apiFetch<{ count: number; deleted?: boolean }>( `/family/groups/${groupId}/leave`, { method: 'POST', body: JSON.stringify(newOwnerUserId ? { newOwnerUserId } : {}) }, token ); } export async function transferFamilyOwnership(groupId: string, newOwnerUserId: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}/transfer-ownership`, { method: 'POST', body: JSON.stringify({ newOwnerUserId }) }, token); } export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) { const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : ''; return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token); } export async function sendChatMessage( roomId: string, body: { type: string; content?: string; isEncrypted?: boolean; replyToId?: string; storageKey?: string; mimeType?: string; metadataJson?: string; poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean }; }, token?: string | null ) { return apiFetch(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token); } export async function fetchUserE2EPublicKey(userId: string, token?: string | null) { return apiFetch<{ userId?: string; e2ePublicKey?: string }>(`/profile/users/${userId}/e2e-public-key`, {}, token); } export async function setUserE2EPublicKey(userId: string, publicKey: string, token?: string | null) { return apiFetch<{ userId?: string; e2ePublicKey?: string }>( `/profile/users/${userId}/e2e-public-key`, { method: 'PATCH', body: JSON.stringify({ publicKey }) }, token ); } export interface ManagedBot { id: string; name: string; username: string; tokenPrefix?: string; description?: string | null; aboutText?: string | null; botPicUrl?: string | null; menuButtonJson?: string | null; webAppUrl?: string | null; isActive?: boolean; isSystemBot?: boolean; ownerId?: string; createdAt?: string; updatedAt?: string; owner?: { id: string; displayName: string; username?: string }; messageCount?: number; chatCount?: number; } export interface AdminBotMetrics { totalBots?: number; activeBots?: number; blockedBots?: number; totalMessages?: number; totalChats?: number; } export async function fetchAdminBots( params: { search?: string; page?: number; limit?: number }, token?: string | null ) { const query = new URLSearchParams(); if (params.search?.trim()) query.set('search', params.search.trim()); if (params.page) query.set('page', String(params.page)); if (params.limit) query.set('limit', String(params.limit)); const suffix = query.toString() ? `?${query.toString()}` : ''; return apiFetch<{ bots?: ManagedBot[]; total?: number }>(`/admin/bots${suffix}`, {}, token); } export async function fetchAdminBotMetrics(token?: string | null) { return apiFetch('/admin/bots/metrics', {}, token); } export async function fetchAdminBotAccounts(search: string | undefined, token?: string | null) { const suffix = search?.trim() ? `?search=${encodeURIComponent(search.trim())}` : ''; return apiFetch<{ users: AdminUser[] }>(`/admin/users/bot-accounts${suffix}`, {}, token); } export async function fetchUserTotpStatus(userId: string, token?: string | null) { return apiFetch(`/security/users/${userId}/totp/status`, {}, token); } export async function adminDisableUserTotp(userId: string, token?: string | null) { return apiFetch(`/admin/users/${userId}/totp/admin-disable`, { method: 'POST' }, token); } export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) { return apiFetch(`/admin/bots/${botId}/active`, { method: 'PATCH', body: JSON.stringify({ isActive }) }, token); } export async function createE2ERoom(groupId: string, peerUserId: string, token?: string | null) { return apiFetch(`/chat/groups/${groupId}/e2e-rooms`, { method: 'POST', body: JSON.stringify({ peerUserId }) }, token); } export async function fetchMyBots(token?: string | null) { return apiFetch<{ bots?: ManagedBot[]; total?: number }>('/bots', {}, token); } export async function createManagedBot(body: { name: string; username: string }, token?: string | null) { return apiFetch<{ bot?: ManagedBot; token?: string }>('/bots', { method: 'POST', body: JSON.stringify(body) }, token); } export async function fetchBot(botId: string, token?: string | null) { return apiFetch(`/bots/${botId}`, {}, token); } export async function updateManagedBot(botId: string, body: { name?: string; username?: string }, token?: string | null) { return apiFetch(`/bots/${botId}`, { method: 'PATCH', body: JSON.stringify(body) }, token); } export async function updateManagedBotProfile( botId: string, body: { description?: string; aboutText?: string; botPicUrl?: string; menuButtonUrl?: string; menuButtonText?: string; menuButtonJson?: string; }, token?: string | null ) { return apiFetch(`/bots/${botId}/profile`, { method: 'PATCH', body: JSON.stringify(body) }, token); } export async function revokeManagedBotToken(botId: string, token?: string | null) { return apiFetch<{ bot?: ManagedBot; token?: string }>(`/bots/${botId}/revoke-token`, { method: 'POST' }, token); } export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) { return apiFetch(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token); } export async function setChatRoomMuted(roomId: string, muted: boolean, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token); } export async function setChatRoomPinned(roomId: string, pinned: boolean, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}`, { method: 'PATCH', body: JSON.stringify({ pinned }) }, token); } export async function editChatMessage(messageId: string, content: string, token?: string | null) { return apiFetch(`/chat/messages/${messageId}`, { method: 'PATCH', body: JSON.stringify({ content }) }, token); } export async function deleteChatMessage(messageId: string, token?: string | null) { return apiFetch(`/chat/messages/${messageId}`, { method: 'DELETE' }, token); } export async function toggleChatMessageReaction(messageId: string, emoji: string, token?: string | null) { return apiFetch(`/chat/messages/${messageId}/reactions`, { method: 'POST', body: JSON.stringify({ emoji }) }, token); } export async function setChatMessagePinned(messageId: string, pinned: boolean, token?: string | null) { return apiFetch(`/chat/messages/${messageId}/pin`, { method: 'POST', body: JSON.stringify({ pinned }) }, token); } export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) { return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, { method: 'POST', body: JSON.stringify({ messageIds }) }, token); } export async function deleteChatRoom(roomId: string, token?: string | null) { return apiFetch<{ count?: number }>(`/chat/rooms/${roomId}`, { method: 'DELETE' }, token); } export async function markChatRoomRead(roomId: string, lastMessageId: string | undefined, token?: string | null) { return apiFetch<{ count: number }>(`/chat/rooms/${roomId}/read`, { method: 'POST', body: JSON.stringify({ lastMessageId }) }, token); } export async function reportChatTyping(roomId: string, token?: string | null) { return apiFetch<{ success?: boolean }>(`/chat/rooms/${roomId}/typing`, { method: 'POST' }, token); } export async function fetchFamilyPresence(groupId: string, token?: string | null) { const accessToken = resolveAccessToken(token); if (!accessToken) { throw new ApiError('Не авторизован', 401, 'NO_TOKEN'); } return apiFetch(`/family/groups/${groupId}/presence`, {}, accessToken, true, { silent: true }); } export type AppReleasePlatform = 'ANDROID' | 'WINDOWS'; export interface AppRelease { id: string; platform: AppReleasePlatform; version: string; versionCode: number; fileName: string; storageKey: string; fileSize: string; sha256: string; releaseNotes?: string; isPublished: boolean; createdAt: string; updatedAt: string; } export interface AppUpdateCheckResponse { updateAvailable: boolean; latest?: AppRelease; } export async function fetchPublicAppReleases(platform?: AppReleasePlatform) { const query = platform ? `?platform=${platform}` : ''; return apiFetch<{ releases: AppRelease[] }>(`/releases${query}`, {}, null, true, { silent: true }); } export async function fetchLatestAppRelease(platform: AppReleasePlatform) { return apiFetch(`/releases/latest/${platform.toLowerCase()}`, {}, null, true, { silent: true }); } export async function checkAppUpdate(platform: AppReleasePlatform, versionCode: number) { return apiFetch( `/releases/check?platform=${platform}&versionCode=${versionCode}`, {}, null, true, { silent: true } ); } export function buildAppReleaseDownloadUrl(platform: AppReleasePlatform, releaseId?: string) { const slug = platform.toLowerCase(); return releaseId ? `/downloads/${slug}/${releaseId}` : `/downloads/${slug}`; } export async function fetchAdminAppReleases(token: string, platform?: AppReleasePlatform) { const query = platform ? `?platform=${platform}` : ''; return apiFetch<{ releases: AppRelease[] }>(`/admin/releases${query}`, {}, token); } export async function uploadAdminAppRelease( token: string, payload: { platform: AppReleasePlatform; version: string; versionCode: number; releaseNotes?: string; file: File; }, onProgress?: (percent: number) => void ) { const formData = new FormData(); formData.append('platform', payload.platform); formData.append('version', payload.version.trim()); formData.append('versionCode', String(payload.versionCode)); if (payload.releaseNotes?.trim()) { formData.append('releaseNotes', payload.releaseNotes.trim()); } formData.append('file', payload.file, payload.file.name); const apiBase = getApiUrl().replace(/\/$/, ''); const response = await platformFetch(`${apiBase}/admin/releases/upload`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }); if (!response.ok) { const body = await response.json().catch(() => ({})); if (response.status === 413) { throw new ApiError( 'Файл слишком большой для загрузки (лимит сервера). Максимальный размер APK — 350 МБ. Попросите администратора обновить nginx: ./install.sh --fix-proxy', 413, 'UPLOAD_TOO_LARGE' ); } const message = typeof body?.message === 'string' ? body.message : 'Не удалось загрузить релиз'; throw new ApiError(message, response.status, 'UPLOAD_FAILED'); } onProgress?.(100); return (await response.json()) as AppRelease; } export async function updateAdminAppRelease( token: string, releaseId: string, patch: { isPublished?: boolean; releaseNotes?: string } ) { return apiFetch(`/admin/releases/${releaseId}`, { method: 'PATCH', body: JSON.stringify(patch) }, token); } export async function deleteAdminAppRelease(token: string, releaseId: string) { return apiFetch<{ deleted: boolean }>(`/admin/releases/${releaseId}`, { method: 'DELETE' }, token); } /** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */ export const WS_URL = configuredWsUrl;