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(/\/$/, ''); 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 { // 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; } 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; 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 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 fetch(`${apiBase}/media/upload`, { method: 'POST', headers: { 'X-Upload-Token': presigned.uploadToken }, body: formData }); if (!response.ok) { throw new Error('Не удалось загрузить файл'); } return; } const response = await fetch(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 && [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; } 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); } 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); } 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); } export async function fetchOAuthClientPublicInfo(clientId: string) { return apiFetch<{ clientId: string; name: string }>(`/oauth/clients/${encodeURIComponent(clientId)}/public`); } 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'); } await ensureApiGatewayReady(); 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; if (!isApiGatewayReady()) { await ensureApiGatewayReady(); } try { await apiFetch<{ synced: boolean }>( '/fedcm/session/sync', { method: 'POST', credentials: 'include' }, token ); } catch { // фоновая синхронизация FedCM cookie } } 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 { 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 SignInEvent { id: string; success: boolean; reason?: string | null; ipAddress?: string | null; userAgent?: string | null; deviceName?: string | null; createdAt: 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 = 4; const GATEWAY_PROBE_MAX_ATTEMPTS = 48; const GATEWAY_PROBE_BASE_DELAY_MS = 450; const GATEWAY_STABLE_PROBES = 3; const API_MAX_CONCURRENT = 6; const API_BURST_MAX_CONCURRENT = 2; const API_BURST_WINDOW_MS = 5000; const API_READY_EVENT = 'idp-api-ready'; const API_NOT_READY_EVENT = 'idp-api-not-ready'; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } let gatewayReadyResolved = false; let gatewayReadyPromise: Promise | null = null; let gatewayReadyAt = 0; let activeApiRequests = 0; const apiRequestWaiters: Array<() => void> = []; export function resetApiGatewayWarmup() { gatewayReadyResolved = false; gatewayReadyPromise = null; } export function invalidateApiGatewayReady() { gatewayReadyResolved = false; gatewayReadyAt = 0; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT)); } } export function isApiGatewayReady() { return gatewayReadyResolved; } export function markApiGatewayReady() { if (gatewayReadyResolved) return; gatewayReadyResolved = true; gatewayReadyAt = Date.now(); 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 = GATEWAY_STABLE_PROBES): Promise { if (typeof window === 'undefined') return; gatewayReadyResolved = false; gatewayReadyAt = 0; let streak = 0; for (let attempt = 0; attempt < 24 && streak < stableProbes; attempt += 1) { if (await probeGatewayAlive()) { streak += 1; if (streak >= stableProbes) { markApiGatewayReady(); return; } await sleep(500); } else { streak = 0; await sleep(Math.min(700 + attempt * 250, 2500)); } } throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE'); } 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 fetch(`${getApiUrl()}/health`, { method: 'GET', cache: 'no-store', signal: AbortSignal.timeout(4500) }); return response.ok; } catch { return false; } } async function waitForGatewayAlive(): Promise { if (gatewayReadyResolved) return; for (let attempt = 0; attempt < GATEWAY_PROBE_MAX_ATTEMPTS; attempt += 1) { if (await probeGatewayAlive()) { markApiGatewayReady(); return; } if (attempt + 1 < GATEWAY_PROBE_MAX_ATTEMPTS) { await sleep(Math.min(GATEWAY_PROBE_BASE_DELAY_MS + attempt * 280, 2800)); } } throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE'); } /** Один общий прогрев: все apiFetch ждут успешный /health, без раннего «fail-open». */ export async function ensureApiGatewayReady(force = false): Promise { if (typeof window === 'undefined') return; if (gatewayReadyResolved && !force) return; if (gatewayReadyPromise) return gatewayReadyPromise; if (force) { gatewayReadyResolved = false; } gatewayReadyPromise = waitForGatewayAlive().finally(() => { gatewayReadyPromise = null; }); return gatewayReadyPromise; } function gatewayRetryDelay(attempt: number): number { return 800 + attempt * 700; } /** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise { let lastNetworkError: unknown; for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) { try { const response = await fetch(url, init); if (response.ok) { markApiGatewayReady(); } if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { invalidateApiGatewayReady(); try { await waitForStableGateway(2); } catch { await sleep(gatewayRetryDelay(attempt)); } continue; } return response; } catch (error) { lastNetworkError = error; if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { invalidateApiGatewayReady(); try { await waitForStableGateway(2); } catch { await sleep(gatewayRetryDelay(attempt)); } continue; } throw mapFetchError(error); } } throw mapFetchError(lastNetworkError); } export async function apiFetch(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise { if (typeof window !== 'undefined' && !gatewayReadyResolved && !path.startsWith('/health')) { await ensureApiGatewayReady(); } 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}` } : {}), ...(options.headers as Record | undefined) }; let response: Response; try { try { response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { ...options, headers }); } 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 (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { const refreshed = await trySilentTokenRefresh(); if (refreshed?.accessToken) { releaseApiSlot(); return apiFetch(path, options, refreshed.accessToken, false); } if (refreshed?.requiresPin) { throw error; } } throw error; } markApiGatewayReady(); return response.json() as Promise; } finally { releaseApiSlot(); } } 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) { const query = new URLSearchParams({ storageKey }); return apiFetch(`/media/users/${userId}/documents/photo-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); } export async function fetchFamilyGroup(groupId: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}`, {}, token); } 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); } 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); } /** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */ export const WS_URL = configuredWsUrl;