import { authorizedFetch } from './auth/tokens'; import { ApiError, emitApiErrorEvent } from './lib/apiError'; import type { EventLimitSummary } from './lib/limitWarnings'; import i18n from './i18n'; type JsonValue = Record; export type TenantAccountProfile = { id: number; name: string; email: string; preferred_locale: string | null; email_verified: boolean; email_verified_at: string | null; }; export type UpdateTenantProfilePayload = { name: string; email: string; preferred_locale?: string | null; current_password?: string; password?: string; password_confirmation?: string; }; export type EventQrInviteLayout = { id: string; name: string; description: string; subtitle: string; badge_label?: string | null; instructions_heading?: string | null; link_heading?: string | null; cta_label?: string | null; cta_caption?: string | null; instructions?: string[]; preview: { background: string | null; background_gradient: { angle: number; stops: string[] } | null; accent: string | null; text: string | null; qr_size_px?: number | null; }; formats: string[]; download_urls?: Record; }; export type TenantEventType = { id: number; slug: string; name: string; name_translations: Record; icon: string | null; settings: Record; created_at?: string | null; updated_at?: string | null; }; export type TenantEvent = { id: number; name: string | Record; slug: string; event_date: string | null; event_type_id: number | null; event_type: TenantEventType | null; status: 'draft' | 'published' | 'archived'; is_active?: boolean; description?: string | null; photo_count?: number; pending_photo_count?: number; like_count?: number; tasks_count?: number; active_invites_count?: number; total_invites_count?: number; engagement_mode?: 'tasks' | 'photo_only'; settings?: Record & { engagement_mode?: 'tasks' | 'photo_only' }; package?: { id: number | string | null; name: string | null; price: number | null; purchased_at: string | null; expires_at: string | null; } | null; limits?: EventLimitSummary | null; [key: string]: unknown; }; export type TenantPhoto = { id: number; filename: string; original_name: string | null; mime_type: string | null; size: number; url: string; thumbnail_url: string; status: string; is_featured: boolean; likes_count: number; uploaded_at: string; uploader_name: string | null; ingest_source?: string | null; caption?: string | null; }; export type EventStats = { total: number; featured: number; likes: number; recent_uploads: number; status: string; is_active: boolean; uploads_total?: number; uploads_24h?: number; likes_total?: number; pending_photos?: number; }; export type PhotoboothStatus = { enabled: boolean; status: string | null; username: string | null; password: string | null; path: string | null; ftp_url: string | null; expires_at: string | null; rate_limit_per_minute: number; ftp: { host: string | null; port: number; require_ftps: boolean; }; }; export type HelpCenterArticleSummary = { slug: string; title: string; summary: string; updated_at?: string; status?: string; translation_state?: string; related?: Array<{ slug: string }>; }; export type HelpCenterArticle = HelpCenterArticleSummary & { body_html?: string; body_markdown?: string; owner?: string; requires_app_version?: string | null; version_introduced?: string; }; export type PaginationMeta = { current_page: number; last_page: number; per_page: number; total: number; }; export type PaginatedResult = { data: T[]; meta: PaginationMeta; }; export type DashboardSummary = { active_events: number; new_photos: number; task_progress: number; credit_balance?: number | null; upcoming_events?: number | null; active_package?: { name: string; expires_at?: string | null; remaining_events?: number | null; } | null; engagement_totals?: { tasks?: number; collections?: number; emotions?: number; }; }; export type TenantOnboardingStatus = { steps: { admin_app_opened_at?: string | null; primary_event_id?: number | string | null; selected_packages?: unknown; branding_completed?: boolean; tasks_configured?: boolean; event_created?: boolean; invite_created?: boolean; }; }; export async function trackOnboarding(step: string, meta?: Record): Promise { try { await authorizedFetch('/api/v1/tenant/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ step, meta }), }); } catch (error) { const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'); emitApiErrorEvent({ message, code: 'onboarding.track_failed' }); console.error('[Onboarding] Failed to track tenant onboarding step', error); } } export async function fetchOnboardingStatus(): Promise { try { const response = await authorizedFetch('/api/v1/tenant/onboarding'); return (await response.json()) as TenantOnboardingStatus; } catch (error) { const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'); emitApiErrorEvent({ message, code: 'onboarding.fetch_failed' }); console.error('[Onboarding] Failed to fetch tenant onboarding status', error); return null; } } function resolveHelpLocale(locale?: string): 'de' | 'en' { if (!locale) { return 'de'; } const normalized = locale.toLowerCase().split('-')[0]; return normalized === 'en' ? 'en' : 'de'; } export async function fetchHelpCenterArticles(locale?: string): Promise { const resolvedLocale = resolveHelpLocale(locale); try { const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale }); const response = await authorizedFetch(`/api/v1/help?${params.toString()}`); if (!response.ok) { throw new Error('Failed to fetch help articles'); } const payload = (await response.json()) as { data?: HelpCenterArticleSummary[] }; return Array.isArray(payload?.data) ? payload.data : []; } catch (error) { const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'); emitApiErrorEvent({ message, code: 'help.fetch_list_failed' }); console.error('[HelpApi] Failed to fetch help articles', error); throw error; } } export async function fetchHelpCenterArticle(slug: string, locale?: string): Promise { const resolvedLocale = resolveHelpLocale(locale); try { const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale }); const response = await authorizedFetch(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`); if (!response.ok) { throw new Error('Failed to fetch help article'); } const payload = (await response.json()) as { data?: HelpCenterArticle }; if (!payload?.data) { throw new Error('Empty help article response'); } return payload.data; } catch (error) { const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'); emitApiErrorEvent({ message, code: 'help.fetch_detail_failed' }); console.error('[HelpApi] Failed to fetch help article', error); throw error; } } export type TenantPackageSummary = { id: number; package_id: number; package_name: string; active: boolean; used_events: number; remaining_events: number | null; price: number | null; currency: string | null; purchased_at: string | null; expires_at: string | null; package_limits: Record | null; }; export type NotificationPreferences = Record; export type NotificationPreferencesMeta = { credit_warning_sent_at?: string | null; credit_warning_threshold?: number | null; }; export type CreditBalance = { balance: number; free_event_granted_at?: string | null; }; export type PaddleTransactionSummary = { id: string | null; status: string | null; amount: number | null; currency: string | null; origin: string | null; checkout_id: string | null; created_at: string | null; updated_at: string | null; receipt_url?: string | null; grand_total?: number | null; tax?: number | null; }; export type CreditLedgerEntry = { id: number; delta: number; reason: string; note: string | null; related_purchase_id: number | null; created_at: string; }; export type TenantTask = { id: number; slug: string; title: string; title_translations: Record; description: string | null; description_translations: Record; example_text: string | null; example_text_translations: Record; priority: 'low' | 'medium' | 'high' | 'urgent' | null; difficulty: 'easy' | 'medium' | 'hard' | null; due_date: string | null; is_completed: boolean; event_type_id: number | null; event_type?: TenantEventType | null; tenant_id: number | null; collection_id: number | null; source_task_id: number | null; source_collection_id: number | null; assigned_events_count: number; assigned_events?: TenantEvent[]; created_at: string | null; updated_at: string | null; }; export type TenantTaskCollection = { id: number; slug: string; name: string; name_translations: Record; description: string | null; description_translations: Record; tenant_id: number | null; is_global: boolean; event_type?: { id: number; slug: string; name: string; name_translations: Record; icon: string | null; } | null; tasks_count: number; position: number | null; source_collection_id: number | null; created_at: string | null; updated_at: string | null; }; export type TenantEmotion = { id: number; name: string; name_translations: Record; description: string | null; description_translations: Record; icon: string; color: string; sort_order: number; is_active: boolean; is_global: boolean; tenant_id: number | null; event_types: Array<{ id: number; slug: string; name: string; name_translations: Record; }>; created_at: string | null; updated_at: string | null; }; export type TaskPayload = Partial<{ title: string; title_translations: Record; description: string | null; description_translations: Record; example_text: string | null; example_text_translations: Record; collection_id: number | null; priority: 'low' | 'medium' | 'high' | 'urgent'; due_date: string | null; is_completed: boolean; difficulty: 'easy' | 'medium' | 'hard'; }>; export type EmotionPayload = Partial<{ name: string; description: string | null; icon: string; color: string; sort_order: number; is_active: boolean; event_type_ids: number[]; }>; export type EventMember = { id: number; name: string; email: string | null; role: 'tenant_admin' | 'member'; status?: 'pending' | 'active' | 'invited' | string; joined_at?: string | null; avatar_url?: string | null; permissions?: string[] | null; user_id?: number | null; }; type EventListResponse = { data?: JsonValue[] }; type EventResponse = { data: JsonValue }; export type EventQrInvite = { id: number; token: string; url: string; label: string | null; qr_code_data_url: string | null; usage_limit: number | null; usage_count: number; expires_at: string | null; revoked_at: string | null; is_active: boolean; created_at: string | null; metadata: Record; layouts: EventQrInviteLayout[]; layouts_url: string | null; }; export type EventToolkitTask = { id: number; title: string; description: string | null; is_completed: boolean; priority?: string | null; }; export type EventToolkit = { event: TenantEvent; metrics: { uploads_total: number; uploads_24h: number; pending_photos: number; active_invites: number; engagement_mode: 'tasks' | 'photo_only'; }; tasks: { summary: { total: number; completed: number; pending: number; }; items: EventToolkitTask[]; }; photos: { pending: TenantPhoto[]; recent: TenantPhoto[]; }; invites: { summary: { total: number; active: number; }; items: EventQrInvite[]; }; alerts: string[]; }; type CreatedEventResponse = { message: string; data: JsonValue; balance: number }; type PhotoResponse = { message: string; data: TenantPhoto }; type EventSavePayload = { name: string; slug: string; event_type_id: number; event_date?: string; status?: 'draft' | 'published' | 'archived'; is_active?: boolean; package_id?: number; settings?: Record; }; type JsonOrThrowOptions = { suppressToast?: boolean; }; async function jsonOrThrow(response: Response, message: string, options: JsonOrThrowOptions = {}): Promise { if (!response.ok) { const body = await safeJson(response); const status = response.status; const errorPayload = body && typeof body === 'object' ? (body as Record).error : null; const errorRecord = errorPayload && typeof errorPayload === 'object' ? (errorPayload as Record) : null; const errorMessage = errorRecord && typeof errorRecord.message === 'string' ? errorRecord.message : message; const errorCode = errorRecord && typeof errorRecord.code === 'string' ? errorRecord.code : undefined; let errorMeta = errorRecord && typeof errorRecord.meta === 'object' ? (errorRecord.meta ?? null) as Record : undefined; if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') { errorMeta = { errors: body.errors as Record, }; } if (!options.suppressToast) { emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta }); } console.error('[API]', errorMessage, status, body); throw new ApiError(errorMessage, status, errorCode, errorMeta); } return (await response.json()) as T; } async function safeJson(response: Response): Promise { try { return (await response.clone().json()) as JsonValue; } catch { return null; } } function buildPagination(payload: JsonValue | null, defaultCount: number): PaginationMeta { const meta = (payload?.meta as Partial) ?? {}; return { current_page: Number(meta.current_page ?? payload?.current_page ?? 1), last_page: Number(meta.last_page ?? payload?.last_page ?? 1), per_page: Number(meta.per_page ?? payload?.per_page ?? defaultCount ?? 0) || defaultCount || 0, total: Number(meta.total ?? payload?.total ?? defaultCount ?? 0) || defaultCount || 0, }; } function translationLocales(): string[] { const locale = i18n.language; const base = locale?.includes('-') ? locale.split('-')[0] : locale; const fallback = ['de', 'en']; return [locale, base, ...fallback].filter( (value, index, self): value is string => Boolean(value) && self.indexOf(value) === index ); } function normalizeTranslationMap(value: unknown, fallback?: string, allowEmpty = false): Record { if (typeof value === 'string') { const map: Record = {}; for (const locale of translationLocales()) { map[locale] = value; } return map; } if (value && typeof value === 'object' && !Array.isArray(value)) { const entries: Record = {}; for (const [key, entry] of Object.entries(value as Record)) { if (typeof entry === 'string') { entries[key] = entry; } } if (Object.keys(entries).length > 0) { return entries; } } if (fallback) { const locales = translationLocales(); return locales.reduce>((acc, locale) => { acc[locale] = fallback; return acc; }, {}); } return allowEmpty ? {} : {}; } function pickTranslatedText(translations: Record, fallback: string): string { const locales = translationLocales(); for (const locale of locales) { if (translations[locale]) { return translations[locale]!; } } const first = Object.values(translations)[0]; if (first) { return first; } return fallback; } function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEventType | null { if (!raw) { return null; } const translations = normalizeTranslationMap((raw as JsonValue).name ?? {}, undefined, true); const fallback = typeof (raw as JsonValue).name === 'string' ? (raw as JsonValue).name : 'Event'; return { id: Number((raw as JsonValue).id ?? 0), slug: String((raw as JsonValue).slug ?? ''), name: pickTranslatedText(translations, fallback ?? 'Event'), name_translations: translations, icon: ((raw as JsonValue).icon ?? null) as string | null, settings: ((raw as JsonValue).settings ?? {}) as Record, created_at: (raw as JsonValue).created_at ?? null, updated_at: (raw as JsonValue).updated_at ?? null, }; } function normalizeEvent(event: JsonValue): TenantEvent { const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null); const settings = ((event.settings ?? {}) as Record) ?? {}; const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as | 'tasks' | 'photo_only'; const normalized: TenantEvent = { ...(event as Record), id: Number(event.id ?? 0), name: event.name ?? '', slug: String(event.slug ?? ''), event_date: typeof event.event_date === 'string' ? event.event_date : (typeof event.date === 'string' ? event.date : null), event_type_id: event.event_type_id !== undefined && event.event_type_id !== null ? Number(event.event_type_id) : null, event_type: normalizedType, status: (event.status ?? 'draft') as TenantEvent['status'], is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined, description: event.description ?? null, photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined, pending_photo_count: event.pending_photo_count !== undefined ? Number(event.pending_photo_count ?? 0) : undefined, like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : event.likes_sum !== undefined ? Number(event.likes_sum ?? 0) : undefined, tasks_count: event.tasks_count !== undefined ? Number(event.tasks_count ?? 0) : undefined, active_invites_count: event.active_invites_count !== undefined ? Number(event.active_invites_count ?? 0) : event.active_join_tokens_count !== undefined ? Number(event.active_join_tokens_count ?? 0) : undefined, total_invites_count: event.total_invites_count !== undefined ? Number(event.total_invites_count ?? 0) : event.total_join_tokens_count !== undefined ? Number(event.total_join_tokens_count ?? 0) : undefined, engagement_mode: engagementMode, settings, package: event.package ?? null, limits: (event.limits ?? null) as EventLimitSummary | null, }; return normalized; } function normalizePhoto(photo: TenantPhoto): TenantPhoto { return { id: photo.id, filename: photo.filename, original_name: photo.original_name ?? null, mime_type: photo.mime_type ?? null, size: Number(photo.size ?? 0), url: photo.url, thumbnail_url: photo.thumbnail_url ?? photo.url, status: photo.status ?? 'approved', is_featured: Boolean(photo.is_featured), likes_count: Number(photo.likes_count ?? 0), uploaded_at: photo.uploaded_at, uploader_name: photo.uploader_name ?? null, caption: photo.caption ?? null, }; } function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null { if (!payload) { return null; } return { active_events: Number(payload.active_events ?? payload.activeEvents ?? 0), new_photos: Number(payload.new_photos ?? payload.newPhotos ?? 0), task_progress: Number(payload.task_progress ?? payload.taskProgress ?? 0), credit_balance: payload.credit_balance ?? payload.creditBalance ?? null, upcoming_events: payload.upcoming_events ?? payload.upcomingEvents ?? null, active_package: payload.active_package ? { name: String(payload.active_package.name ?? 'Aktives Package'), expires_at: payload.active_package.expires_at ?? null, remaining_events: payload.active_package.remaining_events ?? payload.active_package.remainingEvents ?? null, } : null, engagement_totals: { tasks: Number( payload.tasks?.summary?.total ?? payload.tasks_total ?? payload.engagement?.tasks ?? 0, ), collections: Number( payload.task_collections?.summary?.total ?? payload.collections_total ?? payload.engagement?.collections ?? 0, ), emotions: Number( payload.emotions?.summary?.total ?? payload.emotions_total ?? payload.engagement?.emotions ?? 0, ), }, }; } function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { const packageData = pkg.package ?? {}; return { id: Number(pkg.id ?? 0), package_id: Number(pkg.package_id ?? packageData.id ?? 0), package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'), active: Boolean(pkg.active ?? false), used_events: Number(pkg.used_events ?? 0), remaining_events: pkg.remaining_events !== undefined ? Number(pkg.remaining_events) : null, price: packageData.price !== undefined ? Number(packageData.price) : pkg.price ?? null, currency: packageData.currency ?? pkg.currency ?? 'EUR', purchased_at: pkg.purchased_at ?? pkg.created_at ?? null, expires_at: pkg.expires_at ?? pkg.valid_until ?? null, package_limits: pkg.package_limits ?? packageData.limits ?? null, }; } function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary { const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total); const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total); return { id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null, status: entry.status ?? null, amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null, currency: entry.currency ?? entry.currency_code ?? 'EUR', origin: entry.origin ?? null, checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null), created_at: entry.created_at ?? null, updated_at: entry.updated_at ?? null, receipt_url: entry.receipt_url ?? entry.invoice_url ?? null, grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null, tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null, }; } function normalizeTask(task: JsonValue): TenantTask { const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); const exampleTranslations = normalizeTranslationMap(task.example_text ?? {}); const eventType = normalizeEventType(task.event_type ?? task.eventType ?? null); const eventTypeId = typeof task.event_type_id === 'number' ? Number(task.event_type_id) : eventType?.id ?? null; return { id: Number(task.id ?? 0), slug: String(task.slug ?? `task-${task.id ?? ''}`), title: pickTranslatedText(titleTranslations, 'Ohne Titel'), title_translations: titleTranslations, description: Object.keys(descriptionTranslations).length ? pickTranslatedText(descriptionTranslations, '') : null, description_translations: Object.keys(descriptionTranslations).length ? descriptionTranslations : {}, example_text: Object.keys(exampleTranslations).length ? pickTranslatedText(exampleTranslations, '') : null, example_text_translations: Object.keys(exampleTranslations).length ? exampleTranslations : {}, priority: (task.priority ?? null) as TenantTask['priority'], difficulty: (task.difficulty ?? null) as TenantTask['difficulty'], due_date: task.due_date ?? null, is_completed: Boolean(task.is_completed ?? false), event_type_id: eventTypeId, event_type: eventType, tenant_id: task.tenant_id ?? null, collection_id: task.collection_id ?? null, source_task_id: task.source_task_id ?? null, source_collection_id: task.source_collection_id ?? null, assigned_events_count: Number(task.assigned_events_count ?? 0), assigned_events: Array.isArray(task.assigned_events) ? task.assigned_events.map(normalizeEvent) : undefined, created_at: task.created_at ?? null, updated_at: task.updated_at ?? null, }; } function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection { const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}); const descriptionTranslations = normalizeTranslationMap( raw.description_translations ?? raw.description ?? {}, undefined, true ); const eventTypeRaw = raw.event_type ?? raw.eventType ?? null; let eventType: TenantTaskCollection['event_type'] = null; if (eventTypeRaw && typeof eventTypeRaw === 'object') { const eventNameTranslations = normalizeTranslationMap(eventTypeRaw.name ?? {}); eventType = { id: Number(eventTypeRaw.id ?? 0), slug: String(eventTypeRaw.slug ?? ''), name: pickTranslatedText(eventNameTranslations, String(eventTypeRaw.slug ?? '')), name_translations: eventNameTranslations, icon: eventTypeRaw.icon ?? null, }; } return { id: Number(raw.id ?? 0), slug: String(raw.slug ?? `collection-${raw.id ?? ''}`), name: pickTranslatedText(nameTranslations, 'Unbenannte Sammlung'), name_translations: nameTranslations, description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null, description_translations: descriptionTranslations ?? {}, tenant_id: raw.tenant_id ?? null, is_global: !raw.tenant_id, event_type: eventType, tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0), position: raw.position !== undefined ? Number(raw.position) : null, source_collection_id: raw.source_collection_id ?? null, created_at: raw.created_at ?? null, updated_at: raw.updated_at ?? null, }; } function normalizeEmotion(raw: JsonValue): TenantEmotion { const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}); const descriptionTranslations = normalizeTranslationMap( raw.description_translations ?? raw.description ?? {}, undefined, true ); const eventTypes = Array.isArray(raw.event_types ?? raw.eventTypes) ? (raw.event_types ?? raw.eventTypes) : []; return { id: Number(raw.id ?? 0), name: pickTranslatedText(nameTranslations, 'Emotion'), name_translations: nameTranslations, description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null, description_translations: descriptionTranslations ?? {}, icon: String(raw.icon ?? 'lucide-smile'), color: String(raw.color ?? '#6366f1'), sort_order: Number(raw.sort_order ?? 0), is_active: Boolean(raw.is_active ?? true), is_global: !raw.tenant_id, tenant_id: raw.tenant_id ?? null, event_types: (eventTypes as JsonValue[]).map((eventType) => { const translations = normalizeTranslationMap(eventType.name ?? {}); return { id: Number(eventType.id ?? 0), slug: String(eventType.slug ?? ''), name: pickTranslatedText(translations, String(eventType.slug ?? '')), name_translations: translations, }; }), created_at: raw.created_at ?? null, updated_at: raw.updated_at ?? null, }; } function normalizeMember(member: JsonValue): EventMember { return { id: Number(member.id ?? 0), name: String(member.name ?? member.email ?? 'Unbekannt'), email: member.email ?? null, role: (member.role ?? 'member') as EventMember['role'], status: member.status ?? 'active', joined_at: member.joined_at ?? member.created_at ?? null, avatar_url: member.avatar_url ?? member.avatar ?? null, permissions: Array.isArray(member.permissions) ? (member.permissions as string[]) : member.permissions ? String(member.permissions).split(',').map((entry) => entry.trim()) : null, user_id: member.user_id ?? null, }; } function normalizeQrInvite(raw: JsonValue): EventQrInvite { const rawLayouts = Array.isArray(raw.layouts) ? (raw.layouts as JsonValue[]) : []; const layouts: EventQrInviteLayout[] = rawLayouts .map((layout: JsonValue) => { const formats = Array.isArray(layout.formats) ? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0) : []; return { id: String(layout.id ?? ''), name: String(layout.name ?? ''), description: String(layout.description ?? ''), subtitle: String(layout.subtitle ?? ''), badge_label: layout.badge_label ?? null, instructions_heading: layout.instructions_heading ?? null, link_heading: layout.link_heading ?? null, cta_label: layout.cta_label ?? null, cta_caption: layout.cta_caption ?? null, instructions: Array.isArray(layout.instructions) ? (layout.instructions as string[]) : [], preview: { background: layout.preview?.background ?? null, background_gradient: layout.preview?.background_gradient ?? null, accent: layout.preview?.accent ?? null, text: layout.preview?.text ?? null, qr_size_px: layout.preview?.qr_size_px ?? layout.qr?.size_px ?? null, }, formats, download_urls: (layout.download_urls ?? {}) as Record, }; }) .filter((layout: EventQrInviteLayout) => layout.id.length > 0); return { id: Number(raw.id ?? 0), token: String(raw.token ?? ''), url: String(raw.url ?? ''), label: raw.label ?? null, usage_limit: raw.usage_limit ?? null, usage_count: Number(raw.usage_count ?? 0), expires_at: raw.expires_at ?? null, revoked_at: raw.revoked_at ?? null, is_active: Boolean(raw.is_active), created_at: raw.created_at ?? null, metadata: (raw.metadata ?? {}) as Record, layouts, layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null, qr_code_data_url: typeof raw.qr_code_data_url === 'string' && raw.qr_code_data_url.length > 0 ? String(raw.qr_code_data_url) : null, }; } function eventEndpoint(slug: string): string { return `/api/v1/tenant/events/${encodeURIComponent(slug)}`; } function photoboothEndpoint(slug: string): string { return `${eventEndpoint(slug)}/photobooth`; } function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus { const ftp = (payload.ftp ?? {}) as JsonValue; return { enabled: Boolean(payload.enabled), status: typeof payload.status === 'string' ? payload.status : null, username: typeof payload.username === 'string' ? payload.username : null, password: typeof payload.password === 'string' ? payload.password : null, path: typeof payload.path === 'string' ? payload.path : null, ftp_url: typeof payload.ftp_url === 'string' ? payload.ftp_url : null, expires_at: typeof payload.expires_at === 'string' ? payload.expires_at : null, rate_limit_per_minute: Number(payload.rate_limit_per_minute ?? ftp.rate_limit_per_minute ?? 0), ftp: { host: typeof ftp.host === 'string' ? ftp.host : null, port: Number(ftp.port ?? payload.ftp_port ?? 0) || 0, require_ftps: Boolean(ftp.require_ftps ?? payload.require_ftps), }, }; } async function requestPhotoboothStatus(slug: string, path = '', init: RequestInit = {}, errorMessage = 'Failed to fetch photobooth status'): Promise { const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init); const payload = await jsonOrThrow(response, errorMessage); const body = (payload as { data?: JsonValue }).data ?? (payload as JsonValue); return normalizePhotoboothStatus(body ?? {}); } export async function getEvents(options?: { force?: boolean }): Promise { return cachedFetch( CacheKeys.events, async () => { const response = await authorizedFetch('/api/v1/tenant/events'); const data = await jsonOrThrow(response, 'Failed to load events'); return (data.data ?? []).map(normalizeEvent); }, DEFAULT_CACHE_TTL, options?.force === true, ); } export async function createEvent(payload: EventSavePayload): Promise<{ event: TenantEvent; balance: number }> { const response = await authorizedFetch('/api/v1/tenant/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await jsonOrThrow(response, 'Failed to create event'); const result = { event: normalizeEvent(data.data), balance: data.balance }; invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]); return result; } export async function updateEvent(slug: string, payload: Partial): Promise { const response = await authorizedFetch(eventEndpoint(slug), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await jsonOrThrow(response, 'Failed to update event'); const event = normalizeEvent(data.data); invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]); return event; } export async function getEvent(slug: string): Promise { const response = await authorizedFetch(eventEndpoint(slug)); const data = await jsonOrThrow(response, 'Failed to load event'); return normalizeEvent(data.data); } export async function getEventTypes(): Promise { const response = await authorizedFetch('/api/v1/tenant/event-types'); const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types'); const rows = Array.isArray(data.data) ? data.data : []; return rows .map((row) => normalizeEventType(row)) .filter((row): row is TenantEventType => Boolean(row)); } export async function getEventPhotos(slug: string): Promise<{ photos: TenantPhoto[]; limits: EventLimitSummary | null }> { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`); const data = await jsonOrThrow<{ data?: TenantPhoto[]; limits?: EventLimitSummary | null }>( response, 'Failed to load photos' ); return { photos: (data.data ?? []).map(normalizePhoto), limits: (data.limits ?? null) as EventLimitSummary | null, }; } export async function featurePhoto(slug: string, id: number): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' }); const data = await jsonOrThrow(response, 'Failed to feature photo'); return normalizePhoto(data.data); } export async function unfeaturePhoto(slug: string, id: number): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/unfeature`, { method: 'POST' }); const data = await jsonOrThrow(response, 'Failed to unfeature photo'); return normalizePhoto(data.data); } export async function deletePhoto(slug: string, id: number): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, { method: 'DELETE' }); if (!response.ok) { await safeJson(response); throw new Error('Failed to delete photo'); } } export async function updatePhotoVisibility(slug: string, id: number, visible: boolean): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/visibility`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ visible }), }); const data = await jsonOrThrow(response, 'Failed to update photo visibility'); return normalizePhoto(data.data); } export async function toggleEvent(slug: string): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' }); const data = await jsonOrThrow<{ message: string; data: JsonValue }>(response, 'Failed to toggle event'); return normalizeEvent(data.data); } export async function getEventStats(slug: string): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/stats`); const data = await jsonOrThrow(response, 'Failed to load stats'); return { total: Number(data.total ?? 0), featured: Number(data.featured ?? 0), likes: Number(data.likes ?? 0), recent_uploads: Number(data.recent_uploads ?? 0), status: data.status ?? 'draft', is_active: Boolean(data.is_active), uploads_total: Number((data as JsonValue).uploads_total ?? data.total ?? 0), uploads_24h: Number((data as JsonValue).uploads_24h ?? data.recent_uploads ?? 0), likes_total: Number((data as JsonValue).likes_total ?? data.likes ?? 0), pending_photos: Number((data as JsonValue).pending_photos ?? 0), }; } export async function getEventQrInvites(slug: string): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`); const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations'); const list = Array.isArray(payload.data) ? payload.data : []; return list.map(normalizeQrInvite); } export async function createQrInvite( slug: string, payload?: { label?: string; usage_limit?: number; expires_at?: string } ): Promise { const body = JSON.stringify(payload ?? {}); const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation'); return normalizeQrInvite(data.data ?? {}); } export async function revokeEventQrInvite( slug: string, tokenId: number, reason?: string ): Promise { const options: RequestInit = { method: 'DELETE' }; if (reason) { options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify({ reason }); } const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation'); return normalizeQrInvite(data.data ?? {}); } export async function updateEventQrInvite( slug: string, tokenId: number, payload: { label?: string | null; expires_at?: string | null; usage_limit?: number | null; metadata?: Record | null; } ): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update invitation'); return normalizeQrInvite(data.data ?? {}); } export async function getEventToolkit(slug: string): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/toolkit`); const json = await jsonOrThrow>(response, 'Failed to load toolkit'); const metrics = json.metrics ?? {}; const tasks = json.tasks ?? {}; const photos = json.photos ?? {}; const invites = json.invites ?? {}; const pendingPhotosRaw = Array.isArray((photos as Record).pending) ? (photos as Record).pending : []; const recentPhotosRaw = Array.isArray((photos as Record).recent) ? (photos as Record).recent : []; const toolkit: EventToolkit = { event: normalizeEvent(json.event ?? {}), metrics: { uploads_total: Number((metrics as JsonValue).uploads_total ?? 0), uploads_24h: Number((metrics as JsonValue).uploads_24h ?? 0), pending_photos: Number((metrics as JsonValue).pending_photos ?? 0), active_invites: Number((metrics as JsonValue).active_invites ?? 0), engagement_mode: ((metrics as JsonValue).engagement_mode as 'tasks' | 'photo_only') ?? 'tasks', }, tasks: { summary: { total: Number((tasks as JsonValue)?.summary?.total ?? 0), completed: Number((tasks as JsonValue)?.summary?.completed ?? 0), pending: Number((tasks as JsonValue)?.summary?.pending ?? 0), }, items: Array.isArray((tasks as JsonValue)?.items) ? ((tasks as JsonValue).items as JsonValue[]).map((item) => ({ id: Number(item?.id ?? 0), title: String(item?.title ?? ''), description: item?.description !== undefined && item?.description !== null ? String(item.description) : null, is_completed: Boolean(item?.is_completed ?? false), priority: item?.priority !== undefined ? String(item.priority) : null, })) : [], }, photos: { pending: pendingPhotosRaw.map((photo: JsonValue) => normalizePhoto(photo as TenantPhoto)), recent: recentPhotosRaw.map((photo: JsonValue) => normalizePhoto(photo as TenantPhoto)), }, invites: { summary: { total: Number((invites as JsonValue)?.summary?.total ?? 0), active: Number((invites as JsonValue)?.summary?.active ?? 0), }, items: Array.isArray((invites as JsonValue)?.items) ? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item)) : [], }, alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [], }; return toolkit; } export async function getEventPhotoboothStatus(slug: string): Promise { return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status'); } export async function enableEventPhotobooth(slug: string): Promise { return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access'); } export async function rotateEventPhotobooth(slug: string): Promise { return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials'); } export async function disableEventPhotobooth(slug: string): Promise { return requestPhotoboothStatus(slug, '/disable', { method: 'POST' }, 'Failed to disable photobooth access'); } export async function submitTenantFeedback(payload: { category: string; sentiment?: 'positive' | 'neutral' | 'negative'; rating?: number | null; title?: string | null; message?: string | null; event_slug?: string | null; metadata?: Record | null; }): Promise { const response = await authorizedFetch('/api/v1/tenant/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const body = await safeJson(response); console.error('[API] Failed to submit feedback', response.status, body); throw new Error('Failed to submit feedback'); } } export type Package = { id: number; name: string; price: number; max_photos: number | null; max_guests: number | null; gallery_days: number | null; features: Record; }; export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise { const response = await authorizedFetch(`/api/v1/tenant/packages?type=${type}`); const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages'); return data.data ?? []; } type TenantPackagesResponse = { data?: JsonValue[]; active_package?: JsonValue | null; message?: string; }; type LedgerResponse = { data?: JsonValue[]; meta?: Partial; current_page?: number; last_page?: number; per_page?: number; total?: number; }; type TaskCollectionResponse = { data?: JsonValue[]; collection?: JsonValue; message?: string; meta?: Partial; current_page?: number; last_page?: number; per_page?: number; total?: number; }; type MemberResponse = { data?: JsonValue[]; meta?: Partial; current_page?: number; last_page?: number; per_page?: number; total?: number; }; async function fetchTenantPackagesEndpoint(): Promise { const first = await authorizedFetch('/api/v1/tenant/tenant/packages'); if (first.status === 404) { return authorizedFetch('/api/v1/tenant/packages'); } return first; } export async function getDashboardSummary(options?: { force?: boolean }): Promise { return cachedFetch( CacheKeys.dashboard, async () => { const response = await authorizedFetch('/api/v1/tenant/dashboard'); if (response.status === 404) { return null; } if (!response.ok) { const payload = await safeJson(response); const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.'); emitApiErrorEvent({ message: fallbackMessage, status: response.status }); console.error('[API] Failed to load dashboard', response.status, payload); throw new Error(fallbackMessage); } const json = (await response.json()) as JsonValue; return normalizeDashboard(json); }, DEFAULT_CACHE_TTL, options?.force === true, ); } export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{ packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null; }> { return cachedFetch( CacheKeys.packages, async () => { const response = await fetchTenantPackagesEndpoint(); if (!response.ok) { const payload = await safeJson(response); const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'); emitApiErrorEvent({ message: fallbackMessage, status: response.status }); console.error('[API] Failed to load tenant packages', response.status, payload); throw new Error(fallbackMessage); } const data = (await response.json()) as TenantPackagesResponse; const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : []; const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null; return { packages, activePackage }; }, DEFAULT_CACHE_TTL * 5, options?.force === true, ); } export type NotificationPreferenceResponse = { defaults: NotificationPreferences; preferences: NotificationPreferences; overrides: NotificationPreferences | null; meta: NotificationPreferencesMeta | null; }; export async function getNotificationPreferences(): Promise { const response = await authorizedFetch('/api/v1/tenant/settings/notifications'); const payload = await jsonOrThrow<{ data?: { defaults?: NotificationPreferences; preferences?: NotificationPreferences; overrides?: NotificationPreferences; meta?: NotificationPreferencesMeta } }>( response, 'Failed to load notification preferences' ); const data = payload.data ?? {}; return { defaults: data.defaults ?? {}, preferences: data.preferences ?? {}, overrides: data.overrides ?? null, meta: data.meta ?? null, }; } type ProfileResponse = { data?: TenantAccountProfile; message?: string; }; export async function fetchTenantProfile(): Promise { const response = await authorizedFetch('/api/v1/tenant/profile'); const payload = await jsonOrThrow( response, i18n.t('settings.profile.errors.load', 'Profil konnte nicht geladen werden.'), { suppressToast: true } ); if (!payload.data) { throw new Error('Profilantwort war leer.'); } return payload.data; } export async function updateTenantProfile(payload: UpdateTenantProfilePayload): Promise { const response = await authorizedFetch('/api/v1/tenant/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(payload), }); const json = await jsonOrThrow( response, i18n.t('settings.profile.errors.update', 'Profil konnte nicht aktualisiert werden.'), { suppressToast: true } ); if (!json.data) { throw new Error('Profilantwort war leer.'); } return json.data; } export async function updateNotificationPreferences( preferences: NotificationPreferences ): Promise { const response = await authorizedFetch('/api/v1/tenant/settings/notifications', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ preferences }), }); const payload = await jsonOrThrow<{ data?: { preferences?: NotificationPreferences; overrides?: NotificationPreferences; meta?: NotificationPreferencesMeta } }>( response, 'Failed to update notification preferences' ); const data = payload.data ?? {}; return { defaults: {}, preferences: data.preferences ?? preferences, overrides: data.overrides ?? null, meta: data.meta ?? null, }; } export async function getTenantPaddleTransactions(cursor?: string): Promise<{ data: PaddleTransactionSummary[]; nextCursor: string | null; hasMore: boolean; }> { const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''; const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`); if (response.status === 404) { return { data: [], nextCursor: null, hasMore: false }; } if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load Paddle transactions', response.status, payload); throw new Error('Failed to load Paddle transactions'); } const payload = await safeJson(response) ?? {}; const entries = Array.isArray(payload.data) ? payload.data : []; const meta = payload.meta ?? {}; return { data: entries.map(normalizePaddleTransaction), nextCursor: typeof meta.next === 'string' ? meta.next : null, hasMore: Boolean(meta.has_more), }; } export async function getCreditBalance(): Promise { const response = await authorizedFetch('/api/v1/tenant/credits/balance'); if (response.status === 404) { return { balance: 0 }; } const data = await jsonOrThrow(response, 'Failed to load credit balance'); return { balance: Number(data.balance ?? 0), free_event_granted_at: data.free_event_granted_at ?? null }; } export async function getCreditLedger(page = 1): Promise> { const response = await authorizedFetch(`/api/v1/tenant/credits/ledger?page=${page}`); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load credit ledger', response.status, payload); throw new Error('Failed to load credit ledger'); } const json = (await response.json()) as LedgerResponse; const entries = Array.isArray(json.data) ? json.data.map((entry) => ({ id: Number(entry.id ?? 0), delta: Number(entry.delta ?? 0), reason: String(entry.reason ?? 'unknown'), note: entry.note ?? null, related_purchase_id: entry.related_purchase_id ?? null, created_at: entry.created_at ?? '', })) : []; return { data: entries, meta: buildPagination(json as JsonValue, entries.length), }; } export async function createTenantPackagePaymentIntent(packageId: number): Promise { const response = await authorizedFetch('/api/v1/tenant/packages/payment-intent', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ package_id: packageId }), }); const data = await jsonOrThrow<{ client_secret: string }>( response, 'Failed to create package payment intent' ); if (!data.client_secret) { throw new Error('Missing client secret in response'); } return data.client_secret; } export async function completeTenantPackagePurchase(params: { packageId: number; paymentMethodId?: string; paddleTransactionId?: string; }): Promise { const { packageId, paymentMethodId, paddleTransactionId } = params; const payload: Record = { package_id: packageId }; if (paymentMethodId) { payload.payment_method_id = paymentMethodId; } if (paddleTransactionId) { payload.paddle_transaction_id = paddleTransactionId; } const response = await authorizedFetch('/api/v1/tenant/packages/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); await jsonOrThrow(response, 'Failed to complete package purchase'); } export async function assignFreeTenantPackage(packageId: number): Promise { const response = await authorizedFetch('/api/v1/tenant/packages/free', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ package_id: packageId }), }); await jsonOrThrow(response, 'Failed to assign free package'); } export async function createTenantPaddleCheckout(packageId: number): Promise<{ checkout_url: string }> { const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ package_id: packageId }), }); const data = await jsonOrThrow<{ checkout_url: string }>(response, 'Failed to create Paddle checkout'); if (!data.checkout_url) { throw new Error('Missing Paddle checkout URL'); } return { checkout_url: data.checkout_url }; } export async function recordCreditPurchase(payload: { package_id: string; credits_added: number; platform?: string; transaction_id?: string; subscription_active?: boolean; }): Promise { const response = await authorizedFetch('/api/v1/tenant/credits/purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await jsonOrThrow<{ message: string; balance: number; subscription_active?: boolean }>( response, 'Failed to record credit purchase' ); return { balance: Number(data.balance ?? 0) }; } export async function syncCreditBalance(payload: { balance: number; subscription_active?: boolean; last_sync?: string; }): Promise<{ balance: number; subscription_active: boolean; server_time: string }> { const response = await authorizedFetch('/api/v1/tenant/credits/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); return jsonOrThrow(response, 'Failed to sync credit balance'); } export async function getTaskCollections(params: { page?: number; per_page?: number; search?: string; event_type?: string; scope?: 'global' | 'tenant'; } = {}): Promise> { const searchParams = new URLSearchParams(); if (params.page) searchParams.set('page', String(params.page)); if (params.per_page) searchParams.set('per_page', String(params.per_page)); if (params.search) searchParams.set('search', params.search); if (params.event_type) searchParams.set('event_type', params.event_type); if (params.scope) searchParams.set('scope', params.scope); const queryString = searchParams.toString(); const response = await authorizedFetch( `/api/v1/tenant/task-collections${queryString ? `?${queryString}` : ''}` ); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load task collections', response.status, payload); throw new Error('Failed to load task collections'); } const json = (await response.json()) as TaskCollectionResponse; const collections = Array.isArray(json.data) ? json.data.map(normalizeTaskCollection) : []; return { data: collections, meta: buildPagination(json as JsonValue, collections.length), }; } export async function getTaskCollection(collectionId: number): Promise { const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}`); const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to load task collection'); return normalizeTaskCollection(json.data); } export async function importTaskCollection( collectionId: number, eventSlug: string ): Promise { const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}/activate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_slug: eventSlug }), }); const json = await jsonOrThrow(response, 'Failed to import task collection'); if (json.collection) { return normalizeTaskCollection(json.collection); } if (json.data && json.data.length === 1) { return normalizeTaskCollection(json.data[0]!); } throw new Error('Missing collection payload'); } export async function getEmotions(): Promise { const response = await authorizedFetch('/api/v1/tenant/emotions'); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load emotions', response.status, payload); throw new Error('Failed to load emotions'); } const json = (await response.json()) as { data?: JsonValue[] }; return Array.isArray(json.data) ? json.data.map(normalizeEmotion) : []; } export async function createEmotion(payload: EmotionPayload): Promise { const response = await authorizedFetch('/api/v1/tenant/emotions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create emotion'); return normalizeEmotion(json.data); } export async function updateEmotion(emotionId: number, payload: EmotionPayload): Promise { const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update emotion'); return normalizeEmotion(json.data); } export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise> { const searchParams = new URLSearchParams(); if (params.page) searchParams.set('page', String(params.page)); if (params.per_page) searchParams.set('per_page', String(params.per_page)); if (params.search) searchParams.set('search', params.search); const response = await authorizedFetch(`/api/v1/tenant/tasks?${searchParams.toString()}`); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load tasks', response.status, payload); throw new Error('Failed to load tasks'); } const json = (await response.json()) as TaskCollectionResponse; const tasks = Array.isArray(json.data) ? json.data.map(normalizeTask) : []; return { data: tasks, meta: buildPagination(json as JsonValue, tasks.length), }; } export async function createTask(payload: TaskPayload): Promise { const response = await authorizedFetch(`/api/v1/tenant/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create task'); return normalizeTask(data.data); } export async function updateTask(taskId: number, payload: TaskPayload): Promise { const response = await authorizedFetch(`/api/v1/tenant/tasks/${taskId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update task'); return normalizeTask(data.data); } export async function deleteTask(taskId: number): Promise { const response = await authorizedFetch(`/api/v1/tenant/tasks/${taskId}`, { method: 'DELETE', }); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to delete task', response.status, payload); throw new Error('Failed to delete task'); } } export async function assignTasksToEvent(eventId: number, taskIds: number[]): Promise { const response = await authorizedFetch(`/api/v1/tenant/tasks/bulk-assign-event/${eventId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_ids: taskIds }), }); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to assign tasks', response.status, payload); throw new Error('Failed to assign tasks'); } } export async function getEventTasks(eventId: number, page = 1): Promise> { const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}`); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load event tasks', response.status, payload); throw new Error('Failed to load event tasks'); } const json = (await response.json()) as TaskCollectionResponse; const tasks = Array.isArray(json.data) ? json.data.map(normalizeTask) : []; return { data: tasks, meta: buildPagination(json as JsonValue, tasks.length), }; } export async function getEventMembers(eventIdentifier: number | string, page = 1): Promise> { const response = await authorizedFetch( `/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members?page=${page}` ); if (response.status === 404) { return { data: [], meta: { current_page: 1, last_page: 1, per_page: 0, total: 0 } }; } if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load event members', response.status, payload); throw new Error('Failed to load event members'); } const json = (await response.json()) as MemberResponse; const members = Array.isArray(json.data) ? json.data.map(normalizeMember) : []; return { data: members, meta: buildPagination(json as JsonValue, members.length), }; } export async function inviteEventMember( eventIdentifier: number | string, payload: { email: string; role: EventMember['role']; name?: string } ): Promise { const response = await authorizedFetch( `/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), } ); if (response.status === 404) { throw new Error('Mitgliederverwaltung ist fuer dieses Event noch nicht verfuegbar.'); } const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to invite member'); return normalizeMember(data.data); } export async function removeEventMember(eventIdentifier: number | string, memberId: number): Promise { const response = await authorizedFetch( `/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members/${memberId}`, { method: 'DELETE' } ); if (response.status === 404) { throw new Error('Mitglied konnte nicht gefunden werden.'); } if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to remove member', response.status, payload); throw new Error('Failed to remove member'); } } type CacheEntry = { value?: T; expiresAt: number; promise?: Promise; }; const tenantApiCache = new Map>(); const DEFAULT_CACHE_TTL = 60_000; const CacheKeys = { dashboard: 'tenant:dashboard', events: 'tenant:events', packages: 'tenant:packages', } as const; function cachedFetch( key: string, fetcher: () => Promise, ttl: number = DEFAULT_CACHE_TTL, force = false, ): Promise { if (force) { tenantApiCache.delete(key); } const now = Date.now(); const existing = tenantApiCache.get(key) as CacheEntry | undefined; if (!force && existing) { if (existing.promise) { return existing.promise; } if (existing.value !== undefined && existing.expiresAt > now) { return Promise.resolve(existing.value); } } const promise = fetcher() .then((value) => { tenantApiCache.set(key, { value, expiresAt: Date.now() + ttl }); return value; }) .catch((error) => { tenantApiCache.delete(key); throw error; }); tenantApiCache.set(key, { value: existing?.value, expiresAt: existing?.expiresAt ?? 0, promise }); return promise; } export function invalidateTenantApiCache(keys?: string | string[]): void { if (!keys) { tenantApiCache.clear(); return; } const entries = Array.isArray(keys) ? keys : [keys]; for (const key of entries) { tenantApiCache.delete(key); } }