/
guselnikov
/
inst
Обзор
Документация
Войти
/
guselnikov
/
inst
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
admin/src/api/client.ts
931 строка
24 KB
Viktor Guselnikov
fix: API ошибки pipeline и магазин шаблонов
27 июл 2026, 10:33
27 июл 2026, 10:33
45f1ea0
Код
Авторство
О чём код?
const TOKEN_KEY = 'inst_admin_token'; export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } export function setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token); } export function clearToken(): void { localStorage.removeItem(TOKEN_KEY); } function apiBase(): string { const envUrl = import.meta.env.VITE_API_URL; if (envUrl) return `${envUrl.replace(/\/$/, '')}/api/v1`; return '/api/v1'; } export function formatApiError(body: unknown, fallback: string): string { if (!body || typeof body !== 'object') return fallback; const msg = (body as { message?: unknown }).message; if (typeof msg === 'string') return msg; if (Array.isArray(msg)) return msg.join('; '); if (msg && typeof msg === 'object') { const nested = (msg as { message?: unknown }).message; if (typeof nested === 'string') return nested; if (Array.isArray(nested)) return nested.join('; '); } return fallback; } async function throwIfNotOk(response: Response): Promise<void> { if (response.ok) return; const body = await response.json().catch(() => null); if (response.status === 401) { clearToken(); throw new Error('Сессия истекла. Войдите снова.'); } throw new Error(formatApiError(body, response.statusText)); } export async function request<T>(path: string, init: RequestInit = {}): Promise<T> { const headers = new Headers(init.headers); headers.set('Content-Type', 'application/json'); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBase()}${path}`, { ...init, headers }); await throwIfNotOk(response); return response.json() as Promise<T>; } async function parseJsonResponse<T>(response: Response): Promise<T> { await throwIfNotOk(response); return response.json() as Promise<T>; } export interface AuthResponse { accessToken: string; user: { id: string; email: string; organizationId: string }; } export interface IgAccount { id: string; username: string; status: string; createdAt: string; } export interface ParsingJob { id: string; hashtag: string; maxProfiles: number; collectPhone: boolean; collectAvatar: boolean; recentPostsCount: number; audienceId: string | null; status: string; profilesFound: number; profilesScraped: number; igAccountId: string; errorMessage?: string | null; createdAt: string; } export interface ProfileMediaItem { id: string; sortOrder: number; mediaHash: string; fileExt: string; mediaType: string; shortcode: string | null; } export interface FilterRun { id: string; parsingJobId: string; requirePhone: boolean; requireNailMaster: boolean; keywords: string[]; status: string; matchedCount: number; createdAt: string; } export interface LikeJob { id: string; igAccountId: string; mode: string; audienceId?: string | null; maxLikes: number; likesPerProfile: number; followEnabled: boolean; delayMinMs: number; delayMaxMs: number; accountDelayMinMs: number; accountDelayMaxMs: number; status: string; likesDone: number; likesSkipped: number; profilesDone: number; profilesTotal: number; errorsCount: number; errorMessage?: string | null; createdAt: string; } export interface CollectedProfile { id: string; username: string; displayName: string; bio: string; phone: string | null; avatarHash: string | null; scrapedAt: string; media?: ProfileMediaItem[]; rawMeta?: { contacts?: { telegram?: string | null; website?: string | null; email?: string | null; links?: Array<{ type: string; url: string; value?: string }>; }; } | null; } export interface EnrichRun { id: string; filterRunId: string; igAccountId: string; audienceId: string | null; collectAvatar: boolean; recentPostsCount: number; status: string; totalCount: number; enrichedCount: number; errorsCount: number; errorMessage?: string | null; createdAt: string; completedAt: string | null; } export interface VatagoSyncRun { id: string; audienceId: string; status: string; totalCount: number; exportedCount: number; skippedCount: number; errorsCount: number; skipWithoutPhone: boolean; memberDelayMs: number; errorMessage?: string | null; createdAt: string; completedAt: string | null; } export interface VatagoSyncResult { id: string; username: string; status: 'success' | 'idempotent' | 'skipped' | 'error'; vatagoLeadId: string | null; vatagoProjectId: string | null; previewUrl: string | null; errorMessage: string | null; createdAt: string; } export interface FilteredResult { id: string; username: string; displayName: string; bio: string; phone: string | null; } export interface Audience { id: string; name: string; description: string; color: string; memberCount: number; createdAt: string; updatedAt: string; } export interface AudienceMember { id: string; username: string; displayName: string; bio: string; phone: string | null; avatarHash: string | null; source: string; addedAt: string; media?: ProfileMediaItem[]; } export interface AudienceOperationResult { audience: Audience; added: number; updated: number; removed: number; total: number; } export interface IgPostMediaItem { id: string; sortOrder: number; previewUrl: string; } export type IgPostAspectRatio = 'original' | 'square' | 'portrait' | 'landscape'; export interface IgPost { id: string; igAccountId: string | null; caption: string; aspectRatio: IgPostAspectRatio; mediaType: string; status: string; scheduledAt: string | null; igMediaId: string | null; igShortcode: string | null; errorMessage?: string | null; createdAt: string; publishedAt: string | null; mediaItems: IgPostMediaItem[]; } export function login(email: string, password: string) { return request<AuthResponse>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), }); } export function register(email: string, password: string) { return request<AuthResponse>('/auth/register', { method: 'POST', body: JSON.stringify({ email, password }), }); } export function fetchIgAccounts() { return request<IgAccount[]>('/ig-accounts'); } export function createIgAccount(username: string, password: string) { return request<IgAccount>('/ig-accounts', { method: 'POST', body: JSON.stringify({ username, password }), }); } export function confirmIgManual(id: string) { return request<IgAccount>(`/ig-accounts/${id}/confirm-manual`, { method: 'POST' }); } export function igLoginRespond( id: string, body: { action: 'click' | 'type' | 'key'; x?: number; y?: number; text?: string; key?: string; }, ) { return request<{ ok: true }>(`/ig-accounts/${id}/login/respond`, { method: 'POST', body: JSON.stringify(body), }); } export function reloginIgAccount(id: string) { return request<IgAccount>(`/ig-accounts/${id}/relogin`, { method: 'POST' }); } export function fetchParsingJobs() { return request<ParsingJob[]>('/parsing-jobs'); } export function createParsingJob(payload: { hashtag: string; igAccountId: string; audienceId?: string; maxProfiles: number; collectPhone: boolean; collectAvatar?: boolean; recentPostsCount?: number; }) { return request<ParsingJob>('/parsing-jobs', { method: 'POST', body: JSON.stringify(payload), }); } export function startParsingJob(id: string) { return request<ParsingJob>(`/parsing-jobs/${id}/start`, { method: 'POST' }); } export function pauseParsingJob(id: string) { return request<ParsingJob>(`/parsing-jobs/${id}/pause`, { method: 'POST' }); } export function updateParsingJob( id: string, payload: { maxProfiles?: number; collectPhone?: boolean; collectAvatar?: boolean; recentPostsCount?: number; }, ) { return request<ParsingJob>(`/parsing-jobs/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }); } export function resetParsingJob(id: string) { return request<ParsingJob>(`/parsing-jobs/${id}/reset`, { method: 'POST' }); } export function restartParsingJob(id: string) { return request<ParsingJob>(`/parsing-jobs/${id}/restart`, { method: 'POST' }); } export function deleteParsingJob(id: string) { return request<void>(`/parsing-jobs/${id}`, { method: 'DELETE' }); } export function fetchParsingLogs(id: string, tail = 500) { return request<{ lines: string[] }>(`/parsing-jobs/${id}/logs?tail=${tail}`); } export function fetchParsingProfiles(id: string, page = 1) { return request<{ items: CollectedProfile[]; total: number }>( `/parsing-jobs/${id}/profiles?page=${page}&limit=50`, ); } export function fetchFilterRuns() { return request<FilterRun[]>('/filter-runs'); } export function createFilterRun(payload: { parsingJobId: string; requirePhone: boolean; requireNailMaster: boolean; keywords?: string[]; }) { return request<FilterRun>('/filter-runs', { method: 'POST', body: JSON.stringify(payload), }); } export function fetchFilterResults(id: string, page = 1, limit = 200) { return request<{ items: FilteredResult[]; total: number }>( `/filter-runs/${id}/results?page=${page}&limit=${limit}`, ); } export function fetchAudiences() { return request<Audience[]>('/audiences'); } export function createAudience(payload: { name: string; description?: string; color?: string }) { return request<Audience>('/audiences', { method: 'POST', body: JSON.stringify(payload), }); } export function updateAudience( id: string, payload: { name?: string; description?: string; color?: string }, ) { return request<Audience>(`/audiences/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }); } export function deleteAudience(id: string) { return request<void>(`/audiences/${id}`, { method: 'DELETE' }); } export function fetchAudienceMembers(id: string, page = 1, limit = 50, search?: string) { const params = new URLSearchParams({ page: String(page), limit: String(limit) }); if (search?.trim()) params.set('search', search.trim()); return request<{ items: AudienceMember[]; total: number; page: number; limit: number }>( `/audiences/${id}/members?${params}`, ); } export function createAudienceFromFilter(payload: { filterRunId: string; name?: string; description?: string; color?: string; }) { return request<AudienceOperationResult>('/audiences/from-filter', { method: 'POST', body: JSON.stringify(payload), }); } export function enrichAudienceFromFilter(audienceId: string, filterRunId: string) { return request<AudienceOperationResult>(`/audiences/${audienceId}/enrich-from-filter`, { method: 'POST', body: JSON.stringify({ filterRunId }), }); } export function mergeAudiences( targetId: string, payload: { sourceAudienceId: string; createNew?: boolean; newName?: string }, ) { return request<AudienceOperationResult>(`/audiences/${targetId}/merge`, { method: 'POST', body: JSON.stringify(payload), }); } export function intersectAudiences( targetId: string, payload: { otherAudienceId: string; createNew?: boolean; newName?: string }, ) { return request<AudienceOperationResult>(`/audiences/${targetId}/intersect`, { method: 'POST', body: JSON.stringify(payload), }); } export function filterAudience( audienceId: string, payload: { requirePhone?: boolean; requireNailMaster?: boolean; keywords?: string[]; replace?: boolean; }, ) { return request<AudienceOperationResult>(`/audiences/${audienceId}/filter`, { method: 'POST', body: JSON.stringify(payload), }); } export function fetchEnrichRuns(filterRunId?: string) { const q = filterRunId ? `?filterRunId=${filterRunId}` : ''; return request<EnrichRun[]>(`/enrich-runs${q}`); } export function createEnrichRun(payload: { filterRunId: string; igAccountId: string; audienceId?: string; collectAvatar?: boolean; recentPostsCount?: number; }) { return request<EnrichRun>('/enrich-runs', { method: 'POST', body: JSON.stringify(payload), }); } export function fetchVatagoSyncRuns(audienceId?: string) { const q = audienceId ? `?audienceId=${audienceId}` : ''; return request<VatagoSyncRun[]>(`/vatago-sync${q}`); } export function createVatagoSync(payload: { audienceId: string; skipWithoutPhone?: boolean; memberDelayMs?: number; }) { return request<VatagoSyncRun>('/vatago-sync', { method: 'POST', body: JSON.stringify(payload), }); } export function fetchVatagoSyncResults(runId: string) { return request<VatagoSyncResult[]>(`/vatago-sync/${runId}/results`); } export function fetchLikeJobs() { return request<LikeJob[]>('/like-jobs'); } export function createLikeJob(payload: { igAccountId: string; mode?: string; audienceId?: string; likesPerProfile?: number; followEnabled?: boolean; maxLikes?: number; delayMinMs: number; delayMaxMs: number; accountDelayMinMs?: number; accountDelayMaxMs?: number; }) { return request<LikeJob>('/like-jobs', { method: 'POST', body: JSON.stringify(payload), }); } export function startLikeJob(id: string) { return request<LikeJob>(`/like-jobs/${id}/start`, { method: 'POST' }); } export function pauseLikeJob(id: string) { return request<LikeJob>(`/like-jobs/${id}/pause`, { method: 'POST' }); } export function updateLikeJob( id: string, payload: { maxLikes?: number; likesPerProfile?: number; followEnabled?: boolean; delayMinMs?: number; delayMaxMs?: number; accountDelayMinMs?: number; accountDelayMaxMs?: number; }, ) { return request<LikeJob>(`/like-jobs/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }); } export function resetLikeJob(id: string) { return request<LikeJob>(`/like-jobs/${id}/reset`, { method: 'POST' }); } export function restartLikeJob(id: string) { return request<LikeJob>(`/like-jobs/${id}/restart`, { method: 'POST' }); } export function deleteLikeJob(id: string) { return request<void>(`/like-jobs/${id}`, { method: 'DELETE' }); } export function fetchLikeLogs(id: string, tail = 500) { return request<{ lines: string[] }>(`/like-jobs/${id}/logs?tail=${tail}`); } function apiBaseUrl(): string { const envUrl = import.meta.env.VITE_API_URL; if (envUrl) return `${envUrl.replace(/\/$/, '')}/api/v1`; return '/api/v1'; } export function fetchIgPosts() { return request<IgPost[]>('/ig-posts'); } export async function createIgPost( caption: string, images: File[], options?: { igAccountId?: string; aspectRatio?: IgPostAspectRatio; scheduledAt?: string; }, ) { const form = new FormData(); form.append('caption', caption); for (const image of images) { form.append('images', image); } if (options?.igAccountId) form.append('igAccountId', options.igAccountId); if (options?.aspectRatio) form.append('aspectRatio', options.aspectRatio); if (options?.scheduledAt) form.append('scheduledAt', new Date(options.scheduledAt).toISOString()); const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBaseUrl()}/ig-posts`, { method: 'POST', headers, body: form, }); return parseJsonResponse<IgPost>(response); } export function fetchIgPostMediaBlob(previewPath: string): Promise<string> { return fetchMediaBlob(previewPath); } export function mediaUrl(category: 'avatars' | 'posts', hash: string): string { return `/media/${category}/${hash}/file`; } /** Прямой URL для <img src> — img не шлёт Authorization header. */ export function mediaSrc(category: 'avatars' | 'posts', hash: string): string { const token = getToken(); const url = `${apiBaseUrl()}${mediaUrl(category, hash)}`; if (!token) return url; return `${url}?access_token=${encodeURIComponent(token)}`; } /** Authenticated URL for API file paths (e.g. /ig-posts/.../file). */ export function apiFileSrc(path: string): string { const token = getToken(); const url = `${apiBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`; if (!token) return url; const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}access_token=${encodeURIComponent(token)}`; } export async function fetchMediaBlob(path: string): Promise<string> { const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBaseUrl()}${path}`, { headers }); if (!response.ok) throw new Error('Failed to load media'); const contentType = response.headers.get('Content-Type') ?? ''; if (!contentType.startsWith('image/')) throw new Error('Invalid media response'); const blob = await response.blob(); return URL.createObjectURL(blob); } export function publishIgPost(id: string, igAccountId: string) { return request<IgPost>(`/ig-posts/${id}/publish`, { method: 'POST', body: JSON.stringify({ igAccountId }), }); } export function deleteIgPost(id: string) { return request<void>(`/ig-posts/${id}`, { method: 'DELETE' }); } export function updateIgPost( id: string, payload: { caption?: string; aspectRatio?: IgPostAspectRatio; scheduledAt?: string | null; igAccountId?: string | null; mediaOrder?: string[]; }, ) { return request<IgPost>(`/ig-posts/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }); } export async function appendIgPostMedia(id: string, images: File[]) { const form = new FormData(); for (const image of images) form.append('images', image); const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBaseUrl()}/ig-posts/${id}/media`, { method: 'POST', headers, body: form, }); return parseJsonResponse<IgPost>(response); } export function removeIgPostMedia(postId: string, mediaId: string) { return request<IgPost>(`/ig-posts/${postId}/media/${mediaId}`, { method: 'DELETE' }); } export function duplicateIgPost( id: string, payload?: { igAccountId?: string; scheduledAt?: string; caption?: string }, ) { return request<IgPost>(`/ig-posts/${id}/duplicate`, { method: 'POST', body: JSON.stringify(payload ?? {}), }); } export type PostTemplateAspectRatio = 'square' | 'portrait'; export interface PostTemplate { id: string; name: string; aspectRatio: PostTemplateAspectRatio; document: Record<string, unknown>; thumbnailUrl: string | null; createdAt: string; updatedAt: string; } export interface PostTemplateAsset { id: string; kind: 'image' | 'logo'; mime: string; width: number; height: number; previewUrl: string; createdAt: string; } export function fetchPostTemplates() { return request<PostTemplate[]>('/post-templates'); } export function fetchPostTemplate(id: string) { return request<PostTemplate>(`/post-templates/${id}`); } export function createPostTemplate(payload: { name: string; aspectRatio: PostTemplateAspectRatio; document?: Record<string, unknown>; }) { return request<PostTemplate>('/post-templates', { method: 'POST', body: JSON.stringify(payload), }); } export function updatePostTemplate( id: string, payload: { name?: string; document?: Record<string, unknown> }, ) { return request<PostTemplate>(`/post-templates/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }); } export function duplicatePostTemplate(id: string) { return request<PostTemplate>(`/post-templates/${id}/duplicate`, { method: 'POST' }); } export function deletePostTemplate(id: string) { return request<void>(`/post-templates/${id}`, { method: 'DELETE' }); } export async function uploadTemplateAsset( templateId: string, file: File, kind: 'image' | 'logo', ) { const form = new FormData(); form.append('file', file); form.append('kind', kind); const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBaseUrl()}/post-templates/${templateId}/assets`, { method: 'POST', headers, body: form, }); return parseJsonResponse<PostTemplateAsset>(response); } export async function uploadTemplateThumbnail(templateId: string, blob: Blob) { const form = new FormData(); form.append('file', blob, 'thumbnail.jpg'); const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBaseUrl()}/post-templates/${templateId}/thumbnail`, { method: 'POST', headers, body: form, }); return parseJsonResponse<PostTemplate>(response); } export function fetchTemplateAssetBlob(previewPath: string): Promise<string> { return fetchMediaBlob(previewPath); } export function fetchTemplateThumbnailBlob(templateId: string): Promise<string> { return fetchMediaBlob(`/post-templates/${templateId}/thumbnail/file`); } export type DesignTemplateSource = 'cursor' | 'ai_studio' | 'manual'; export interface DesignTemplate { id: string; name: string; description: string; formatId: string; recipeId: string | null; tags: string[]; source: DesignTemplateSource; thumbnailUrl: string | null; createdAt: string; updatedAt: string; document?: Record<string, unknown>; } export function fetchDesignTemplates(params?: { formatId?: string; tag?: string; search?: string; }) { const query = new URLSearchParams(); if (params?.formatId) query.set('formatId', params.formatId); if (params?.tag) query.set('tag', params.tag); if (params?.search) query.set('search', params.search); const suffix = query.toString() ? `?${query.toString()}` : ''; return request<DesignTemplate[]>(`/design-templates${suffix}`); } export function fetchDesignTemplate(id: string) { return request<DesignTemplate>(`/design-templates/${id}`); } export function deleteDesignTemplate(id: string) { return request<void>(`/design-templates/${id}`, { method: 'DELETE' }); } export function fetchDesignTemplateThumbnailBlob(templateId: string): Promise<string> { return fetchMediaBlob(`/design-templates/${templateId}/thumbnail/file`); } export interface IgPostSlide { id: string; sortOrder: number; design: Record<string, unknown> | null; templateId: string | null; } export function fetchIgPostSlides(postId: string) { return request<IgPostSlide[]>(`/ig-posts/${postId}/slides`); } export async function createIgPostFromDesign(payload: { caption: string; aspectRatio?: IgPostAspectRatio; igAccountId?: string; scheduledAt?: string; slides: Array<{ design: Record<string, unknown>; templateId?: string | null }>; images: Blob[]; }) { const form = new FormData(); form.append('caption', payload.caption); form.append('slides', JSON.stringify(payload.slides)); if (payload.aspectRatio) form.append('aspectRatio', payload.aspectRatio); if (payload.igAccountId) form.append('igAccountId', payload.igAccountId); if (payload.scheduledAt) form.append('scheduledAt', new Date(payload.scheduledAt).toISOString()); payload.images.forEach((blob, index) => { form.append('images', blob, `slide-${index}.jpg`); }); const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBaseUrl()}/ig-posts/from-design`, { method: 'POST', headers, body: form, }); return parseJsonResponse<IgPost>(response); } export async function updateIgPostFromDesign( id: string, payload: { caption?: string; aspectRatio?: IgPostAspectRatio; igAccountId?: string | null; scheduledAt?: string | null; slides: Array<{ design: Record<string, unknown>; templateId?: string | null }>; images: Blob[]; }, ) { const form = new FormData(); form.append('slides', JSON.stringify(payload.slides)); if (payload.caption !== undefined) form.append('caption', payload.caption); if (payload.aspectRatio) form.append('aspectRatio', payload.aspectRatio); if (payload.igAccountId !== undefined) { form.append('igAccountId', payload.igAccountId ?? ''); } if (payload.scheduledAt !== undefined) { form.append( 'scheduledAt', payload.scheduledAt ? new Date(payload.scheduledAt).toISOString() : '', ); } payload.images.forEach((blob, index) => { form.append('images', blob, `slide-${index}.jpg`); }); const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); const response = await fetch(`${apiBaseUrl()}/ig-posts/${id}/from-design`, { method: 'PATCH', headers, body: form, }); return parseJsonResponse<IgPost>(response); }