2259 lines
76 KiB
TypeScript
2259 lines
76 KiB
TypeScript
import { authorizedFetch } from './auth/tokens';
|
|
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
|
import type { EventLimitSummary } from './lib/limitWarnings';
|
|
import i18n from './i18n';
|
|
|
|
type JsonValue = Record<string, unknown>;
|
|
|
|
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<string, string>;
|
|
};
|
|
|
|
export type TenantEventType = {
|
|
id: number;
|
|
slug: string;
|
|
name: string;
|
|
name_translations: Record<string, string>;
|
|
icon: string | null;
|
|
settings: Record<string, unknown>;
|
|
created_at?: string | null;
|
|
updated_at?: string | null;
|
|
};
|
|
|
|
export type TenantEvent = {
|
|
id: number;
|
|
name: string | Record<string, string>;
|
|
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<string, unknown> & { 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;
|
|
addons?: EventAddonSummary[];
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type GuestNotificationSummary = {
|
|
id: number;
|
|
type: string;
|
|
title: string;
|
|
body: string | null;
|
|
status: 'draft' | 'active' | 'archived';
|
|
audience_scope: 'all' | 'guest';
|
|
target_identifier?: string | null;
|
|
payload?: Record<string, unknown> | null;
|
|
priority: number;
|
|
created_at: string | null;
|
|
expires_at: string | null;
|
|
};
|
|
|
|
export type SendGuestNotificationPayload = {
|
|
title: string;
|
|
message: string;
|
|
type?: string;
|
|
audience?: 'all' | 'guest';
|
|
guest_identifier?: string | null;
|
|
cta?: { label: string; url: string } | null;
|
|
expires_in_minutes?: number | null;
|
|
priority?: number | null;
|
|
};
|
|
|
|
export type TenantPhoto = {
|
|
id: number;
|
|
filename: string | null;
|
|
original_name: string | null;
|
|
mime_type: string | null;
|
|
size: number;
|
|
url: string | null;
|
|
thumbnail_url: string | null;
|
|
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 EventAddonCheckout = {
|
|
addon_key: string;
|
|
quantity?: number;
|
|
checkout_url: string | null;
|
|
checkout_id: string | null;
|
|
expires_at: string | null;
|
|
};
|
|
|
|
export type EventAddonCatalogItem = {
|
|
key: string;
|
|
label: string;
|
|
price_id: string | null;
|
|
increments?: Record<string, number>;
|
|
};
|
|
|
|
export type EventAddonSummary = {
|
|
id: number;
|
|
key: string;
|
|
label?: string | null;
|
|
status: 'pending' | 'completed' | 'failed';
|
|
extra_photos: number;
|
|
extra_guests: number;
|
|
extra_gallery_days: number;
|
|
purchased_at: string | null;
|
|
};
|
|
|
|
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<T> = {
|
|
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<string, unknown>): Promise<void> {
|
|
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<TenantOnboardingStatus | null> {
|
|
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<HelpCenterArticleSummary[]> {
|
|
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<HelpCenterArticle> {
|
|
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<string, unknown> | null;
|
|
};
|
|
|
|
export type NotificationPreferences = Record<string, boolean>;
|
|
|
|
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 TenantAddonEventSummary = {
|
|
id: number;
|
|
slug: string;
|
|
name: string | Record<string, string> | null;
|
|
};
|
|
|
|
export type TenantAddonHistoryEntry = {
|
|
id: number;
|
|
addon_key: string;
|
|
label?: string | null;
|
|
event: TenantAddonEventSummary | null;
|
|
amount: number | null;
|
|
currency: string | null;
|
|
status: 'pending' | 'completed' | 'failed';
|
|
purchased_at: string | null;
|
|
extra_photos: number;
|
|
extra_guests: number;
|
|
extra_gallery_days: number;
|
|
quantity: number;
|
|
receipt_url?: 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<string, string>;
|
|
description: string | null;
|
|
description_translations: Record<string, string | null>;
|
|
example_text: string | null;
|
|
example_text_translations: Record<string, string>;
|
|
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<string, string>;
|
|
description: string | null;
|
|
description_translations: Record<string, string | null>;
|
|
tenant_id: number | null;
|
|
is_global: boolean;
|
|
event_type?: {
|
|
id: number;
|
|
slug: string;
|
|
name: string;
|
|
name_translations: Record<string, string>;
|
|
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<string, string>;
|
|
description: string | null;
|
|
description_translations: Record<string, string | null>;
|
|
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<string, string>;
|
|
}>;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
};
|
|
|
|
export type TaskPayload = Partial<{
|
|
title: string;
|
|
title_translations: Record<string, string>;
|
|
description: string | null;
|
|
description_translations: Record<string, string | null>;
|
|
example_text: string | null;
|
|
example_text_translations: Record<string, string | null>;
|
|
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<string, unknown>;
|
|
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[];
|
|
};
|
|
notifications?: {
|
|
summary: {
|
|
total: number;
|
|
last_sent_at: string | null;
|
|
by_type: Record<string, number>;
|
|
broadcasts: {
|
|
total: number;
|
|
last_title: string | null;
|
|
last_sent_at: string | null;
|
|
};
|
|
};
|
|
recent: EventToolkitNotification[];
|
|
};
|
|
alerts: string[];
|
|
};
|
|
|
|
export type EventToolkitNotification = {
|
|
id: number;
|
|
title: string;
|
|
type: string;
|
|
status: string;
|
|
audience_scope: string;
|
|
created_at: 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;
|
|
settings?: Record<string, unknown>;
|
|
};
|
|
|
|
type JsonOrThrowOptions = {
|
|
suppressToast?: boolean;
|
|
};
|
|
|
|
async function jsonOrThrow<T>(response: Response, message: string, options: JsonOrThrowOptions = {}): Promise<T> {
|
|
if (!response.ok) {
|
|
const body = await safeJson(response);
|
|
const status = response.status;
|
|
const errorPayload = body && typeof body === 'object' ? (body as Record<string, unknown>).error : null;
|
|
const errorRecord = errorPayload && typeof errorPayload === 'object'
|
|
? (errorPayload as Record<string, unknown>)
|
|
: 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<string, unknown>
|
|
: undefined;
|
|
|
|
if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') {
|
|
errorMeta = {
|
|
errors: body.errors as Record<string, unknown>,
|
|
};
|
|
}
|
|
|
|
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<JsonValue | null> {
|
|
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<PaginationMeta>) ?? {};
|
|
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<string, string> {
|
|
if (typeof value === 'string') {
|
|
const map: Record<string, string> = {};
|
|
for (const locale of translationLocales()) {
|
|
map[locale] = value;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
const entries: Record<string, string> = {};
|
|
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
if (typeof entry === 'string') {
|
|
entries[key] = entry;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(entries).length > 0) {
|
|
return entries;
|
|
}
|
|
}
|
|
|
|
if (fallback) {
|
|
const locales = translationLocales();
|
|
return locales.reduce<Record<string, string>>((acc, locale) => {
|
|
acc[locale] = fallback;
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
return allowEmpty ? {} : {};
|
|
}
|
|
|
|
function pickTranslatedText(translations: Record<string, string>, 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<string, unknown>,
|
|
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<string, unknown>) ?? {};
|
|
const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as
|
|
| 'tasks'
|
|
| 'photo_only';
|
|
const normalized: TenantEvent = {
|
|
...(event as Record<string, unknown>),
|
|
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 normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryEntry {
|
|
let event: TenantAddonEventSummary | null = null;
|
|
|
|
if (entry.event && typeof entry.event === 'object') {
|
|
const rawEvent = entry.event as JsonValue;
|
|
const id = Number((rawEvent as { id?: unknown }).id ?? 0);
|
|
const slugValue = (rawEvent as { slug?: unknown }).slug;
|
|
const rawName = (rawEvent as { name?: unknown }).name ?? null;
|
|
let name: TenantAddonEventSummary['name'] = null;
|
|
|
|
if (typeof rawName === 'string') {
|
|
name = rawName;
|
|
} else if (rawName && typeof rawName === 'object') {
|
|
name = normalizeTranslationMap(rawName, undefined, true);
|
|
}
|
|
|
|
event = {
|
|
id,
|
|
slug: typeof slugValue === 'string' ? slugValue : '',
|
|
name,
|
|
};
|
|
}
|
|
|
|
const amountValue = entry.amount;
|
|
|
|
return {
|
|
id: Number(entry.id ?? 0),
|
|
addon_key: String(entry.addon_key ?? ''),
|
|
label: typeof entry.label === 'string' ? entry.label : null,
|
|
event,
|
|
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
|
currency: typeof entry.currency === 'string' ? entry.currency : null,
|
|
status: (entry.status as TenantAddonHistoryEntry['status']) ?? 'pending',
|
|
purchased_at: typeof entry.purchased_at === 'string' ? entry.purchased_at : null,
|
|
extra_photos: Number(entry.extra_photos ?? 0),
|
|
extra_guests: Number(entry.extra_guests ?? 0),
|
|
extra_gallery_days: Number(entry.extra_gallery_days ?? 0),
|
|
quantity: Number(entry.quantity ?? 1),
|
|
receipt_url: typeof entry.receipt_url === 'string' ? entry.receipt_url : 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<string, string>,
|
|
};
|
|
})
|
|
.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<string, unknown>,
|
|
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 normalizeGuestNotification(raw: JsonValue): GuestNotificationSummary | null {
|
|
if (!raw || typeof raw !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const record = raw as Record<string, JsonValue>;
|
|
const status = typeof record.status === 'string' ? record.status : null;
|
|
const audience = typeof record.audience_scope === 'string' ? record.audience_scope : null;
|
|
|
|
return {
|
|
id: Number(record.id ?? 0),
|
|
type: typeof record.type === 'string' ? record.type : 'broadcast',
|
|
title: typeof record.title === 'string' ? record.title : '',
|
|
body: typeof record.body === 'string' ? record.body : null,
|
|
status: status === 'draft' || status === 'archived' || status === 'active' ? status : 'active',
|
|
audience_scope: audience === 'guest' || audience === 'all' ? audience : 'all',
|
|
target_identifier: typeof record.target_identifier === 'string' ? record.target_identifier : null,
|
|
payload: (record.payload as Record<string, unknown>) ?? null,
|
|
priority: Number(record.priority ?? 0),
|
|
created_at: typeof record.created_at === 'string' ? record.created_at : null,
|
|
expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
|
|
};
|
|
}
|
|
|
|
function eventEndpoint(slug: string): string {
|
|
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
|
|
}
|
|
|
|
function guestNotificationsEndpoint(slug: string): string {
|
|
return `${eventEndpoint(slug)}/guest-notifications`;
|
|
}
|
|
|
|
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<PhotoboothStatus> {
|
|
const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init);
|
|
const payload = await jsonOrThrow<JsonValue | { data: JsonValue }>(response, errorMessage);
|
|
const body = (payload as { data?: JsonValue }).data ?? (payload as JsonValue);
|
|
|
|
return normalizePhotoboothStatus(body ?? {});
|
|
}
|
|
|
|
export async function getEvents(options?: { force?: boolean }): Promise<TenantEvent[]> {
|
|
return cachedFetch(
|
|
CacheKeys.events,
|
|
async () => {
|
|
const response = await authorizedFetch('/api/v1/tenant/events');
|
|
const data = await jsonOrThrow<EventListResponse>(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<CreatedEventResponse>(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<EventSavePayload>): Promise<TenantEvent> {
|
|
const response = await authorizedFetch(eventEndpoint(slug), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await jsonOrThrow<EventResponse>(response, 'Failed to update event');
|
|
const event = normalizeEvent(data.data);
|
|
invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]);
|
|
return event;
|
|
}
|
|
|
|
export async function getEvent(slug: string): Promise<TenantEvent> {
|
|
const response = await authorizedFetch(eventEndpoint(slug));
|
|
const data = await jsonOrThrow<EventResponse>(response, 'Failed to load event');
|
|
return normalizeEvent(data.data);
|
|
}
|
|
|
|
export async function createEventAddonCheckout(
|
|
eventSlug: string,
|
|
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
|
|
): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> {
|
|
const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(params),
|
|
});
|
|
|
|
return await jsonOrThrow<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }>(
|
|
response,
|
|
'Failed to create addon checkout'
|
|
);
|
|
}
|
|
|
|
export async function getAddonCatalog(): Promise<EventAddonCatalogItem[]> {
|
|
const response = await authorizedFetch('/api/v1/tenant/addons/catalog');
|
|
const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons');
|
|
return data.data ?? [];
|
|
}
|
|
|
|
export async function getEventTypes(): Promise<TenantEventType[]> {
|
|
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<TenantPhoto> {
|
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' });
|
|
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to feature photo');
|
|
return normalizePhoto(data.data);
|
|
}
|
|
|
|
export async function unfeaturePhoto(slug: string, id: number): Promise<TenantPhoto> {
|
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/unfeature`, { method: 'POST' });
|
|
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to unfeature photo');
|
|
return normalizePhoto(data.data);
|
|
}
|
|
|
|
export async function deletePhoto(slug: string, id: number): Promise<void> {
|
|
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<TenantPhoto> {
|
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/visibility`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ visible }),
|
|
});
|
|
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to update photo visibility');
|
|
return normalizePhoto(data.data);
|
|
}
|
|
|
|
export async function toggleEvent(slug: string): Promise<TenantEvent> {
|
|
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<EventStats> {
|
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/stats`);
|
|
const data = await jsonOrThrow<EventStats>(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<EventQrInvite[]> {
|
|
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<EventQrInvite> {
|
|
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<EventQrInvite> {
|
|
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<string, unknown> | null;
|
|
}
|
|
): Promise<EventQrInvite> {
|
|
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<EventToolkit> {
|
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/toolkit`);
|
|
const json = await jsonOrThrow<Record<string, JsonValue>>(response, 'Failed to load toolkit');
|
|
|
|
const metrics = json.metrics ?? {};
|
|
const tasks = json.tasks ?? {};
|
|
const photos = json.photos ?? {};
|
|
const invites = json.invites ?? {};
|
|
const notifications = normalizeToolkitNotifications(json.notifications ?? null);
|
|
|
|
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
|
|
? (photos as Record<string, JsonValue>).pending
|
|
: [];
|
|
const recentPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).recent)
|
|
? (photos as Record<string, JsonValue>).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))
|
|
: [],
|
|
},
|
|
notifications,
|
|
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
|
|
};
|
|
|
|
return toolkit;
|
|
}
|
|
|
|
function normalizeToolkitNotifications(payload: JsonValue | null | undefined): EventToolkit['notifications'] | undefined {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return undefined;
|
|
}
|
|
|
|
const record = payload as Record<string, JsonValue>;
|
|
const summaryRaw = record.summary ?? {};
|
|
const broadcastsRaw = summaryRaw?.broadcasts ?? {};
|
|
|
|
return {
|
|
summary: {
|
|
total: Number(summaryRaw?.total ?? 0),
|
|
last_sent_at: typeof summaryRaw?.last_sent_at === 'string' ? summaryRaw.last_sent_at : null,
|
|
by_type: (summaryRaw?.by_type ?? {}) as Record<string, number>,
|
|
broadcasts: {
|
|
total: Number(broadcastsRaw?.total ?? 0),
|
|
last_title: typeof broadcastsRaw?.last_title === 'string' ? broadcastsRaw.last_title : null,
|
|
last_sent_at: typeof broadcastsRaw?.last_sent_at === 'string' ? broadcastsRaw.last_sent_at : null,
|
|
},
|
|
},
|
|
recent: Array.isArray(record.recent)
|
|
? (record.recent as JsonValue[]).map((row) => normalizeToolkitNotification(row))
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function normalizeToolkitNotification(row: JsonValue): EventToolkitNotification {
|
|
if (!row || typeof row !== 'object') {
|
|
return {
|
|
id: 0,
|
|
title: '',
|
|
type: 'broadcast',
|
|
status: 'active',
|
|
audience_scope: 'all',
|
|
created_at: null,
|
|
};
|
|
}
|
|
|
|
const record = row as Record<string, JsonValue>;
|
|
|
|
return {
|
|
id: Number(record.id ?? 0),
|
|
title: typeof record.title === 'string' ? record.title : '',
|
|
type: typeof record.type === 'string' ? record.type : 'broadcast',
|
|
status: typeof record.status === 'string' ? record.status : 'active',
|
|
audience_scope: typeof record.audience_scope === 'string' ? record.audience_scope : 'all',
|
|
created_at: typeof record.created_at === 'string' ? record.created_at : null,
|
|
};
|
|
}
|
|
|
|
export async function listGuestNotifications(slug: string): Promise<GuestNotificationSummary[]> {
|
|
const response = await authorizedFetch(guestNotificationsEndpoint(slug));
|
|
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load guest notifications');
|
|
const rows = Array.isArray(data.data) ? data.data : [];
|
|
|
|
return rows
|
|
.map((row) => normalizeGuestNotification(row))
|
|
.filter((row): row is GuestNotificationSummary => Boolean(row));
|
|
}
|
|
|
|
export async function sendGuestNotification(
|
|
slug: string,
|
|
payload: SendGuestNotificationPayload
|
|
): Promise<GuestNotificationSummary> {
|
|
const response = await authorizedFetch(guestNotificationsEndpoint(slug), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to send guest notification');
|
|
const fallback = normalizeGuestNotification({
|
|
id: 0,
|
|
type: payload.type ?? 'broadcast',
|
|
title: payload.title,
|
|
body: payload.message,
|
|
status: 'active',
|
|
audience_scope: payload.audience ?? 'all',
|
|
target_identifier: payload.guest_identifier ?? null,
|
|
payload: payload.cta ? { cta: payload.cta } : null,
|
|
priority: payload.priority ?? 0,
|
|
created_at: new Date().toISOString(),
|
|
expires_at: null,
|
|
});
|
|
|
|
const normalized = normalizeGuestNotification(data.data ?? {}) ?? fallback;
|
|
|
|
if (!normalized) {
|
|
throw new Error('Failed to normalize guest notification');
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
export async function getEventPhotoboothStatus(slug: string): Promise<PhotoboothStatus> {
|
|
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
|
|
}
|
|
|
|
export async function enableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
|
return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access');
|
|
}
|
|
|
|
export async function rotateEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
|
return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials');
|
|
}
|
|
|
|
export async function disableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
|
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<string, unknown> | null;
|
|
}): Promise<void> {
|
|
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<string, boolean>;
|
|
};
|
|
|
|
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
|
|
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<PaginationMeta>;
|
|
current_page?: number;
|
|
last_page?: number;
|
|
per_page?: number;
|
|
total?: number;
|
|
};
|
|
|
|
type TaskCollectionResponse = {
|
|
data?: JsonValue[];
|
|
collection?: JsonValue;
|
|
message?: string;
|
|
meta?: Partial<PaginationMeta>;
|
|
current_page?: number;
|
|
last_page?: number;
|
|
per_page?: number;
|
|
total?: number;
|
|
};
|
|
|
|
type MemberResponse = {
|
|
data?: JsonValue[];
|
|
meta?: Partial<PaginationMeta>;
|
|
current_page?: number;
|
|
last_page?: number;
|
|
per_page?: number;
|
|
total?: number;
|
|
};
|
|
|
|
async function fetchTenantPackagesEndpoint(): Promise<Response> {
|
|
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<DashboardSummary | null> {
|
|
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<NotificationPreferenceResponse> {
|
|
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<TenantAccountProfile> {
|
|
const response = await authorizedFetch('/api/v1/tenant/profile');
|
|
const payload = await jsonOrThrow<ProfileResponse>(
|
|
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<TenantAccountProfile> {
|
|
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<ProfileResponse>(
|
|
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<NotificationPreferenceResponse> {
|
|
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 getTenantAddonHistory(page = 1, perPage = 25): Promise<{
|
|
data: TenantAddonHistoryEntry[];
|
|
meta: PaginationMeta;
|
|
}> {
|
|
const params = new URLSearchParams({
|
|
page: String(Math.max(1, page)),
|
|
per_page: String(Math.max(1, Math.min(perPage, 100))),
|
|
});
|
|
|
|
const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`);
|
|
|
|
if (response.status === 404) {
|
|
return {
|
|
data: [],
|
|
meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 },
|
|
};
|
|
}
|
|
|
|
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<PaginationMeta>; current_page?: number; last_page?: number; per_page?: number; total?: number }>(
|
|
response,
|
|
'Failed to load add-on history'
|
|
);
|
|
|
|
const rows = Array.isArray(payload.data) ? payload.data.map((row) => normalizeTenantAddonHistoryEntry(row)) : [];
|
|
const metaSource = payload.meta ?? payload;
|
|
|
|
const meta: PaginationMeta = {
|
|
current_page: Number(metaSource.current_page ?? 1),
|
|
last_page: Number(metaSource.last_page ?? 1),
|
|
per_page: Number(metaSource.per_page ?? perPage),
|
|
total: Number(metaSource.total ?? rows.length),
|
|
};
|
|
|
|
return { data: rows, meta };
|
|
}
|
|
|
|
export async function getCreditBalance(): Promise<CreditBalance> {
|
|
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
|
|
if (response.status === 404) {
|
|
return { balance: 0 };
|
|
}
|
|
const data = await jsonOrThrow<CreditBalance>(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<PaginatedResult<CreditLedgerEntry>> {
|
|
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<string> {
|
|
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<void> {
|
|
const { packageId, paymentMethodId, paddleTransactionId } = params;
|
|
const payload: Record<string, unknown> = { 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<void> {
|
|
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<CreditBalance> {
|
|
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<PaginatedResult<TenantTaskCollection>> {
|
|
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<TenantTaskCollection> {
|
|
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<TenantTaskCollection> {
|
|
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<TaskCollectionResponse>(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<TenantEmotion[]> {
|
|
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<TenantEmotion> {
|
|
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<TenantEmotion> {
|
|
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<PaginatedResult<TenantTask>> {
|
|
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<TenantTask> {
|
|
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<TenantTask> {
|
|
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<void> {
|
|
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<void> {
|
|
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<PaginatedResult<TenantTask>> {
|
|
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<PaginatedResult<EventMember>> {
|
|
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<EventMember> {
|
|
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<void> {
|
|
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<T> = {
|
|
value?: T;
|
|
expiresAt: number;
|
|
promise?: Promise<T>;
|
|
};
|
|
|
|
const tenantApiCache = new Map<string, CacheEntry<unknown>>();
|
|
const DEFAULT_CACHE_TTL = 60_000;
|
|
|
|
const CacheKeys = {
|
|
dashboard: 'tenant:dashboard',
|
|
events: 'tenant:events',
|
|
packages: 'tenant:packages',
|
|
} as const;
|
|
|
|
function cachedFetch<T>(
|
|
key: string,
|
|
fetcher: () => Promise<T>,
|
|
ttl: number = DEFAULT_CACHE_TTL,
|
|
force = false,
|
|
): Promise<T> {
|
|
if (force) {
|
|
tenantApiCache.delete(key);
|
|
}
|
|
|
|
const now = Date.now();
|
|
const existing = tenantApiCache.get(key) as CacheEntry<T> | 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);
|
|
}
|
|
}
|