1338 lines
40 KiB
TypeScript
1338 lines
40 KiB
TypeScript
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) {
|
||
return apiFetch<QrLoginSession>('/auth/advanced/qr/session', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
deviceName,
|
||
fingerprint: getDeviceFingerprint(),
|
||
deviceType: 'WEB'
|
||
})
|
||
});
|
||
}
|
||
|
||
export async function pollQrLoginSession(sessionId: string) {
|
||
return apiFetch<QrLoginSession>(`/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';
|
||
|
||
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<ApiError> {
|
||
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.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<AuthSessionResponse> {
|
||
return apiFetch<AuthSessionResponse>('/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<OAuthConsentCheckResponse>(`/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<RefreshSessionResponse> {
|
||
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<RefreshSessionResponse>;
|
||
}
|
||
|
||
export async function syncFedcmSession(accessToken: string) {
|
||
if (typeof window === 'undefined' || !accessToken.trim()) return;
|
||
try {
|
||
await ensureApiGatewayReady();
|
||
await fetchWithGatewayRetry(`${getApiUrl()}/fedcm/session/sync`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${accessToken.trim()}` },
|
||
credentials: 'include'
|
||
});
|
||
} catch {
|
||
// фоновая синхронизация FedCM cookie
|
||
}
|
||
}
|
||
|
||
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
|
||
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;
|
||
createdAt: string;
|
||
}
|
||
|
||
export interface ActiveSession {
|
||
id: string;
|
||
userId: string;
|
||
deviceId?: string | null;
|
||
pinVerified: boolean;
|
||
status: string;
|
||
ipAddress?: string | null;
|
||
userAgent?: string | null;
|
||
expiresAt: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
async function parseError(response: Response): Promise<string> {
|
||
const error = await parseErrorResponse(response);
|
||
return error.message;
|
||
}
|
||
|
||
export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | 'value' | 'description' | 'isSecret'>) {
|
||
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 = 6;
|
||
const GATEWAY_WARMUP_MAX_ATTEMPTS = 24;
|
||
const GATEWAY_WARMUP_DELAY_MS = 300;
|
||
|
||
function sleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
let gatewayReadyResolved = false;
|
||
let gatewayReadyPromise: Promise<void> | null = null;
|
||
|
||
export function resetApiGatewayWarmup() {
|
||
gatewayReadyResolved = false;
|
||
gatewayReadyPromise = null;
|
||
}
|
||
|
||
export function markApiGatewayReady() {
|
||
gatewayReadyResolved = true;
|
||
}
|
||
|
||
async function probeGatewayHealth(): Promise<boolean> {
|
||
try {
|
||
const response = await fetch(`${getApiUrl()}/health`, {
|
||
method: 'GET',
|
||
cache: 'no-store'
|
||
});
|
||
return response.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Ждёт готовности api-gateway (кратковременные 502 после перезапуска nginx/upstream). */
|
||
export async function ensureApiGatewayReady(force = false): Promise<void> {
|
||
if (typeof window === 'undefined') return;
|
||
if (gatewayReadyResolved && !force) return;
|
||
if (gatewayReadyPromise && !force) return gatewayReadyPromise;
|
||
|
||
gatewayReadyPromise = (async () => {
|
||
for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) {
|
||
if (await probeGatewayHealth()) {
|
||
gatewayReadyResolved = true;
|
||
return;
|
||
}
|
||
await sleep(GATEWAY_WARMUP_DELAY_MS);
|
||
}
|
||
gatewayReadyResolved = true;
|
||
})();
|
||
|
||
return gatewayReadyPromise;
|
||
}
|
||
|
||
/** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */
|
||
async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Response> {
|
||
let lastNetworkError: unknown;
|
||
for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) {
|
||
try {
|
||
const response = await fetch(url, init);
|
||
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
||
await sleep(400 * (attempt + 1));
|
||
continue;
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
lastNetworkError = error;
|
||
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
||
await sleep(400 * (attempt + 1));
|
||
continue;
|
||
}
|
||
throw mapFetchError(error);
|
||
}
|
||
}
|
||
throw mapFetchError(lastNetworkError);
|
||
}
|
||
|
||
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
|
||
if (typeof window !== 'undefined' && path !== '/health' && !path.startsWith('/health?')) {
|
||
await ensureApiGatewayReady();
|
||
}
|
||
|
||
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
|
||
const method = (options.method ?? 'GET').toUpperCase();
|
||
const hasBody = options.body !== undefined && options.body !== null;
|
||
const headers: Record<string, string> = {
|
||
...(hasBody || method === 'POST' || method === 'PUT' || method === 'PATCH'
|
||
? { 'Content-Type': 'application/json' }
|
||
: {}),
|
||
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
||
...(options.headers as Record<string, string> | undefined)
|
||
};
|
||
|
||
let response: Response;
|
||
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) {
|
||
return apiFetch<T>(path, options, refreshed.accessToken, false);
|
||
}
|
||
if (refreshed?.requiresPin) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
|
||
return response.json() as Promise<T>;
|
||
}
|
||
|
||
export function getDeviceFingerprint(): string {
|
||
if (typeof window === 'undefined') return 'server';
|
||
const key = 'lendry_device_fingerprint';
|
||
const existing = window.localStorage.getItem(key);
|
||
if (existing) return existing;
|
||
const value = crypto.randomUUID();
|
||
window.localStorage.setItem(key, value);
|
||
return value;
|
||
}
|
||
|
||
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<MediaAccessResponse>(`/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<AccountDeletionStatus & { userId: string }>(
|
||
`/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<AccountDeletionStatus>(`/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<UserAddress>(`/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) {
|
||
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token);
|
||
}
|
||
|
||
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
|
||
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, {}, token);
|
||
}
|
||
|
||
export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) {
|
||
return apiFetch<FamilyGroup>(`/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<FamilyInvite>(`/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<FamilyInvite>(`/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<ChatRoom>(`/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<ChatRoom>(`/chat/rooms/${roomId}/members`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ memberUserId })
|
||
}, token);
|
||
}
|
||
|
||
export async function removeChatRoomMember(roomId: string, memberUserId: string, token?: string | null) {
|
||
return apiFetch<ChatRoom>(`/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<FamilyGroup>(`/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<ChatMessage>(`/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<AdminBotMetrics>('/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<TotpStatusResponse>(`/security/users/${userId}/totp/status`, {}, token);
|
||
}
|
||
|
||
export async function adminDisableUserTotp(userId: string, token?: string | null) {
|
||
return apiFetch<TotpStatusResponse>(`/admin/users/${userId}/totp/admin-disable`, { method: 'POST' }, token);
|
||
}
|
||
|
||
export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) {
|
||
return apiFetch<ManagedBot>(`/admin/bots/${botId}/active`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ isActive })
|
||
}, token);
|
||
}
|
||
|
||
export async function createE2ERoom(groupId: string, peerUserId: string, token?: string | null) {
|
||
return apiFetch<ChatRoom>(`/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<ManagedBot>(`/bots/${botId}`, {}, token);
|
||
}
|
||
|
||
export async function updateManagedBot(botId: string, body: { name?: string; username?: string }, token?: string | null) {
|
||
return apiFetch<ManagedBot>(`/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<ManagedBot>(`/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<ChatMessage>(`/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<ChatRoom>(`/chat/rooms/${roomId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ pinned })
|
||
}, token);
|
||
}
|
||
|
||
export async function editChatMessage(messageId: string, content: string, token?: string | null) {
|
||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'PATCH', body: JSON.stringify({ content }) }, token);
|
||
}
|
||
|
||
export async function deleteChatMessage(messageId: string, token?: string | null) {
|
||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'DELETE' }, token);
|
||
}
|
||
|
||
export async function toggleChatMessageReaction(messageId: string, emoji: string, token?: string | null) {
|
||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/reactions`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ emoji })
|
||
}, token);
|
||
}
|
||
|
||
export async function setChatMessagePinned(messageId: string, pinned: boolean, token?: string | null) {
|
||
return apiFetch<ChatMessage>(`/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) {
|
||
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, token);
|
||
}
|
||
|
||
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */
|
||
export const WS_URL = configuredWsUrl;
|