import { authorizedFetch } from './auth/tokens'; import i18n from './i18n'; type JsonValue = Record; export type EventJoinTokenLayout = { id: string; name: string; description: string; subtitle: string; preview: { background: string | null; background_gradient: { angle: number; stops: string[] } | null; accent: string | null; text: string | 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; like_count?: number; package?: { id: number | string | null; name: string | null; price: number | null; purchased_at: string | null; expires_at: string | null; } | 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; }; export type EventStats = { total: number; featured: number; likes: number; recent_uploads: number; status: string; is_active: boolean; }; 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; }; 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 CreditBalance = { balance: number; free_event_granted_at?: string | 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; 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' | 'guest' | string; status?: 'pending' | 'active' | 'invited' | string; joined_at?: string | null; avatar_url?: string | null; }; type EventListResponse = { data?: JsonValue[] }; type EventResponse = { data: JsonValue }; export type EventJoinToken = { id: number; token: string; url: string; label: 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: EventJoinTokenLayout[]; layouts_url: string | null; }; 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; }; async function jsonOrThrow(response: Response, message: string): Promise { if (!response.ok) { const body = await safeJson(response); console.error('[API]', message, response.status, body); throw new Error(message); } 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 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, like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined, package: event.package ?? 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, }; } 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, }; } 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 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 ?? {}); return { id: Number(task.id ?? 0), tenant_id: task.tenant_id ?? null, 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), 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 ?? {}, 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 ?? {}, 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, }; } function normalizeJoinToken(raw: JsonValue): EventJoinToken { const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : []; const layouts: EventJoinTokenLayout[] = rawLayouts .map((layout: any) => { 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 ?? ''), preview: { background: layout.preview?.background ?? null, background_gradient: layout.preview?.background_gradient ?? null, accent: layout.preview?.accent ?? null, text: layout.preview?.text ?? null, }, formats, download_urls: (layout.download_urls ?? {}) as Record, }; }) .filter((layout: EventJoinTokenLayout) => 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, }; } function eventEndpoint(slug: string): string { return `/api/v1/tenant/events/${encodeURIComponent(slug)}`; } export async function getEvents(): Promise { const response = await authorizedFetch('/api/v1/tenant/events'); const data = await jsonOrThrow(response, 'Failed to load events'); return (data.data ?? []).map(normalizeEvent); } 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'); return { event: normalizeEvent(data.data), balance: data.balance }; } 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'); return normalizeEvent(data.data); } 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 { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`); const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos'); return (data.data ?? []).map(normalizePhoto); } 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 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), }; } export async function getEventJoinTokens(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(normalizeJoinToken); } export async function createInviteLink( 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 normalizeJoinToken(data.data ?? {}); } export async function revokeEventJoinToken( 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 normalizeJoinToken(data.data ?? {}); } 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(): Promise { const response = await authorizedFetch('/api/v1/tenant/dashboard'); if (response.status === 404) { return null; } if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load dashboard', response.status, payload); throw new Error('Failed to load dashboard'); } const json = (await response.json()) as JsonValue; return normalizeDashboard(json); } export async function getTenantPackagesOverview(): Promise<{ packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null; }> { const response = await fetchTenantPackagesEndpoint(); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load tenant packages', response.status, payload); throw new Error('Failed to load tenant packages'); } 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 }; } 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; paypalOrderId?: string; }): Promise { const { packageId, paymentMethodId, paypalOrderId } = params; const payload: Record = { package_id: packageId }; if (paymentMethodId) { payload.payment_method_id = paymentMethodId; } if (paypalOrderId) { payload.paypal_order_id = paypalOrderId; } 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 createTenantPayPalOrder(packageId: number): Promise { const response = await authorizedFetch('/api/v1/tenant/packages/paypal-create', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ package_id: packageId }), }); const data = await jsonOrThrow<{ orderID: string }>(response, 'Failed to create PayPal order'); if (!data.orderID) { throw new Error('Missing PayPal order ID'); } return data.orderID; } export async function captureTenantPayPalOrder(orderId: string): Promise { const response = await authorizedFetch('/api/v1/tenant/packages/paypal-capture', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ order_id: orderId }), }); await jsonOrThrow(response, 'Failed to capture PayPal order'); } 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?: string; 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'); } }