// src/lib/integrations/meta/MetaBaseClient.ts // Base Client für Meta Graph API (Facebook + Instagram) import type { MetaUser, FacebookPage, InstagramBusinessAccount, MetaPaginatedResponse, MetaErrorResponse, } from '@/types/meta' import { META_CONFIG } from './oauth' // ============================================================================= // Error Types // ============================================================================= export class MetaApiError extends Error { code: number subcode?: number type: string fbtraceId: string constructor(error: MetaErrorResponse['error']) { super(error.message) this.name = 'MetaApiError' this.code = error.code this.subcode = error.error_subcode this.type = error.type this.fbtraceId = error.fbtrace_id } isRateLimitError(): boolean { return this.code === 4 || this.code === 17 || this.code === 32 } isAuthError(): boolean { return this.code === 190 || this.code === 102 } isPermissionError(): boolean { return this.code === 10 || this.code === 200 || this.code === 230 } } // ============================================================================= // Base Client // ============================================================================= export class MetaBaseClient { protected accessToken: string protected apiVersion: string protected baseUrl: string constructor(accessToken: string) { this.accessToken = accessToken this.apiVersion = META_CONFIG.apiVersion this.baseUrl = META_CONFIG.baseUrl } // =========================================================================== // Core Request Method // =========================================================================== protected async request( endpoint: string, options: { method?: 'GET' | 'POST' | 'DELETE' params?: Record body?: Record } = {} ): Promise { const { method = 'GET', params = {}, body } = options // Build URL with params const url = new URL(`${this.baseUrl}/${this.apiVersion}/${endpoint}`) // Add access token url.searchParams.set('access_token', this.accessToken) // Add other params for (const [key, value] of Object.entries(params)) { if (value !== undefined) { url.searchParams.set(key, String(value)) } } // Build request options const fetchOptions: RequestInit = { method, headers: { 'Content-Type': 'application/json', }, } if (body && method !== 'GET') { fetchOptions.body = JSON.stringify(body) } // Execute request const response = await fetch(url.toString(), fetchOptions) const data = await response.json() // Handle errors if (!response.ok || data.error) { const error = data.error || { message: 'Unknown error', type: 'UnknownError', code: response.status, fbtrace_id: 'unknown', } throw new MetaApiError(error) } return data as T } // =========================================================================== // Paginated Request Helper // =========================================================================== protected async requestPaginated( endpoint: string, options: { params?: Record limit?: number maxPages?: number } = {} ): Promise { const { params = {}, limit = 100, maxPages = 10 } = options const allItems: T[] = [] let pageCount = 0 let nextUrl: string | undefined // Initial request const initialParams = { ...params, limit } const response = await this.request>(endpoint, { params: initialParams, }) allItems.push(...response.data) nextUrl = response.paging?.next pageCount++ // Fetch additional pages while (nextUrl && pageCount < maxPages) { const nextResponse = await fetch(nextUrl) const nextData: MetaPaginatedResponse = await nextResponse.json() if ('error' in (nextData as unknown as MetaErrorResponse)) { throw new MetaApiError((nextData as unknown as MetaErrorResponse).error) } allItems.push(...nextData.data) nextUrl = nextData.paging?.next pageCount++ // Rate limiting await this.sleep(100) } return allItems } // =========================================================================== // User & Account Methods // =========================================================================== /** * Hole Informationen über den authentifizierten User */ async getMe(): Promise { return this.request('me', { params: { fields: 'id,name,email', }, }) } /** * Hole alle Facebook Pages des Users */ async getPages(): Promise { const response = await this.request>( 'me/accounts', { params: { fields: 'id,name,access_token,category,category_list,tasks,instagram_business_account', }, } ) return response.data } /** * Hole Instagram Business Account für eine Page */ async getInstagramAccount(pageId: string): Promise { try { const response = await this.request<{ instagram_business_account?: InstagramBusinessAccount }>(`${pageId}`, { params: { fields: 'instagram_business_account{id,username,name,profile_picture_url,followers_count,follows_count,media_count,biography,website}', }, }) return response.instagram_business_account || null } catch (error) { if (error instanceof MetaApiError && error.isPermissionError()) { return null } throw error } } /** * Hole alle Instagram Business Accounts des Users (über alle Pages) */ async getAllInstagramAccounts(): Promise< Array<{ page: FacebookPage instagram: InstagramBusinessAccount }> > { const pages = await this.getPages() const results: Array<{ page: FacebookPage instagram: InstagramBusinessAccount }> = [] for (const page of pages) { if (page.instagram_business_account?.id) { const instagram = await this.getInstagramAccount(page.id) if (instagram) { results.push({ page, instagram }) } } // Rate limiting await this.sleep(100) } return results } // =========================================================================== // Token Management // =========================================================================== /** * Aktualisiert den Access Token */ setAccessToken(token: string): void { this.accessToken = token } /** * Gibt den aktuellen Access Token zurück */ getAccessToken(): string { return this.accessToken } // =========================================================================== // Utility Methods // =========================================================================== protected sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Rate Limiting Helper - wartet bei Rate Limit Errors */ protected async withRetry( fn: () => Promise, maxRetries: number = 3, initialDelay: number = 1000 ): Promise { let lastError: Error | undefined let delay = initialDelay for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn() } catch (error) { lastError = error as Error if (error instanceof MetaApiError && error.isRateLimitError()) { console.warn( `[MetaClient] Rate limit hit, waiting ${delay}ms before retry ${attempt + 1}/${maxRetries}` ) await this.sleep(delay) delay *= 2 // Exponential backoff } else { throw error } } } throw lastError } } export default MetaBaseClient