feat: implement AI styling foundation and billing scope rework
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 20:01:58 +01:00
parent df00deb0df
commit 36bed12ff9
80 changed files with 8944 additions and 49 deletions

View File

@@ -86,6 +86,13 @@ export type ControlRoomSettings = {
force_review_uploaders?: ControlRoomUploaderRule[];
};
export type EventAiEditingSettings = {
enabled?: boolean;
allow_custom_prompt?: boolean;
allowed_style_keys?: string[];
policy_message?: string | null;
};
export type LiveShowLink = {
token: string;
url: string;
@@ -116,6 +123,7 @@ export type TenantEvent = {
guest_upload_visibility?: 'review' | 'immediate';
live_show?: LiveShowSettings;
control_room?: ControlRoomSettings;
ai_editing?: EventAiEditingSettings;
watermark?: WatermarkSettings;
watermark_allowed?: boolean | null;
watermark_removal_allowed?: boolean | null;
@@ -129,6 +137,17 @@ export type TenantEvent = {
expires_at: string | null;
branding_allowed?: boolean | null;
watermark_allowed?: boolean | null;
features?: string[] | null;
} | null;
capabilities?: {
ai_styling?: boolean;
ai_styling_granted_by?: 'package' | 'addon' | null;
ai_styling_required_feature?: string | null;
ai_styling_addon_keys?: string[] | null;
ai_styling_event_enabled?: boolean | null;
ai_styling_allow_custom_prompt?: boolean | null;
ai_styling_allowed_style_keys?: string[] | null;
ai_styling_policy_message?: string | null;
} | null;
limits?: EventLimitSummary | null;
addons?: EventAddonSummary[];
@@ -136,6 +155,28 @@ export type TenantEvent = {
[key: string]: unknown;
};
export type AiEditStyle = {
id: number;
key: string;
name: string;
category?: string | null;
description?: string | null;
provider?: string | null;
provider_model?: string | null;
requires_source_image?: boolean;
is_premium?: boolean;
metadata?: Record<string, unknown>;
};
export type AiEditUsageSummary = {
event_id: number;
total: number;
failed_total: number;
status_counts: Record<string, number>;
safety_counts: Record<string, number>;
last_requested_at: string | null;
};
export type GuestNotificationSummary = {
id: number;
type: string;
@@ -624,6 +665,11 @@ export type TenantAddonHistoryEntry = {
receipt_url?: string | null;
};
export type TenantBillingAddonScope = {
type: 'tenant' | 'event';
event: TenantAddonEventSummary | null;
};
export type CreditLedgerEntry = {
id: number;
delta: number;
@@ -856,6 +902,7 @@ type EventSavePayload = {
settings?: Record<string, unknown> & {
live_show?: LiveShowSettings;
control_room?: ControlRoomSettings;
ai_editing?: EventAiEditingSettings;
watermark?: WatermarkSettings;
watermark_serve_originals?: boolean | null;
};
@@ -999,12 +1046,33 @@ function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEven
};
}
function normalizeFeatureList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (value && typeof value === 'object') {
return Object.entries(value as Record<string, unknown>)
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => key.trim())
.filter((key) => key.length > 0);
}
return [];
}
function normalizeEvent(event: JsonValue): TenantEvent {
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
const settings = ((event.settings ?? {}) as Record<string, unknown>) ?? {};
const eventPackage = event.package && typeof event.package === 'object' ? (event.package as JsonValue) : null;
const capabilities =
event.capabilities && typeof event.capabilities === 'object' ? (event.capabilities as JsonValue) : null;
const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as
| 'tasks'
| 'photo_only';
const eventAddons = Array.isArray(event.addons) ? (event.addons as JsonValue[]) : [];
const normalized: TenantEvent = {
...(event as Record<string, unknown>),
id: Number(event.id ?? 0),
@@ -1043,8 +1111,69 @@ function normalizeEvent(event: JsonValue): TenantEvent {
: undefined,
engagement_mode: engagementMode,
settings,
package: event.package ?? null,
package: eventPackage
? {
id: eventPackage.id ?? null,
name: typeof eventPackage.name === 'string' ? eventPackage.name : null,
price: eventPackage.price !== undefined && eventPackage.price !== null ? Number(eventPackage.price) : null,
purchased_at: typeof eventPackage.purchased_at === 'string' ? eventPackage.purchased_at : null,
expires_at: typeof eventPackage.expires_at === 'string' ? eventPackage.expires_at : null,
branding_allowed:
eventPackage.branding_allowed === undefined ? null : Boolean(eventPackage.branding_allowed),
watermark_allowed:
eventPackage.watermark_allowed === undefined ? null : Boolean(eventPackage.watermark_allowed),
features: normalizeFeatureList(eventPackage.features),
}
: null,
capabilities: capabilities
? {
ai_styling:
capabilities.ai_styling === undefined || capabilities.ai_styling === null
? undefined
: Boolean(capabilities.ai_styling),
ai_styling_granted_by:
capabilities.ai_styling_granted_by === 'package' || capabilities.ai_styling_granted_by === 'addon'
? (capabilities.ai_styling_granted_by as 'package' | 'addon')
: null,
ai_styling_required_feature:
typeof capabilities.ai_styling_required_feature === 'string'
? capabilities.ai_styling_required_feature
: null,
ai_styling_addon_keys: normalizeFeatureList(capabilities.ai_styling_addon_keys),
ai_styling_event_enabled:
capabilities.ai_styling_event_enabled === undefined || capabilities.ai_styling_event_enabled === null
? null
: Boolean(capabilities.ai_styling_event_enabled),
ai_styling_allow_custom_prompt:
capabilities.ai_styling_allow_custom_prompt === undefined || capabilities.ai_styling_allow_custom_prompt === null
? null
: Boolean(capabilities.ai_styling_allow_custom_prompt),
ai_styling_allowed_style_keys: normalizeFeatureList(capabilities.ai_styling_allowed_style_keys),
ai_styling_policy_message:
typeof capabilities.ai_styling_policy_message === 'string'
? capabilities.ai_styling_policy_message
: null,
}
: null,
limits: (event.limits ?? null) as EventLimitSummary | null,
addons: eventAddons
.map((row) => {
if (!row || typeof row !== 'object' || Array.isArray(row)) {
return null;
}
return {
id: Number(row.id ?? 0),
key: typeof row.key === 'string' ? row.key : '',
label: typeof row.label === 'string' ? row.label : null,
status: row.status === 'completed' || row.status === 'failed' ? row.status : 'pending',
extra_photos: Number(row.extra_photos ?? 0),
extra_guests: Number(row.extra_guests ?? 0),
extra_gallery_days: Number(row.extra_gallery_days ?? 0),
purchased_at: typeof row.purchased_at === 'string' ? row.purchased_at : null,
} as EventAddonSummary;
})
.filter((row): row is EventAddonSummary => Boolean(row)),
member_permissions: Array.isArray(event.member_permissions)
? (event.member_permissions as string[])
: event.member_permissions
@@ -1079,6 +1208,49 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
};
}
function normalizeAiEditStyle(row: JsonValue): AiEditStyle | null {
if (!row || typeof row !== 'object') {
return null;
}
const id = Number((row as { id?: unknown }).id ?? 0);
const key = typeof (row as { key?: unknown }).key === 'string' ? String((row as { key?: unknown }).key) : '';
const name = typeof (row as { name?: unknown }).name === 'string' ? String((row as { name?: unknown }).name) : '';
if (id <= 0 || key === '' || name === '') {
return null;
}
const metadata = (row as { metadata?: unknown }).metadata;
return {
id,
key,
name,
category: typeof (row as { category?: unknown }).category === 'string' ? String((row as { category?: unknown }).category) : null,
description:
typeof (row as { description?: unknown }).description === 'string'
? String((row as { description?: unknown }).description)
: null,
provider: typeof (row as { provider?: unknown }).provider === 'string' ? String((row as { provider?: unknown }).provider) : null,
provider_model:
typeof (row as { provider_model?: unknown }).provider_model === 'string'
? String((row as { provider_model?: unknown }).provider_model)
: null,
requires_source_image:
(row as { requires_source_image?: unknown }).requires_source_image === undefined
? undefined
: Boolean((row as { requires_source_image?: unknown }).requires_source_image),
is_premium:
(row as { is_premium?: unknown }).is_premium === undefined
? undefined
: Boolean((row as { is_premium?: unknown }).is_premium),
metadata: metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)
: {},
};
}
function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null {
if (!payload) {
return null;
@@ -1759,6 +1931,63 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
return normalizeEvent(data.data);
}
export async function getEventAiStyles(slug: string): Promise<{
styles: AiEditStyle[];
meta: {
required_feature?: string | null;
addon_keys?: string[];
event_enabled?: boolean;
allow_custom_prompt?: boolean;
allowed_style_keys?: string[];
policy_message?: string | null;
};
}> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/ai-styles`);
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Record<string, unknown> }>(
response,
'Failed to load AI styles'
);
const rows = Array.isArray(payload.data) ? payload.data : [];
const meta = payload.meta ?? {};
return {
styles: rows
.map((row) => normalizeAiEditStyle(row))
.filter((row): row is AiEditStyle => Boolean(row)),
meta: {
required_feature: typeof meta.required_feature === 'string' ? meta.required_feature : null,
addon_keys: normalizeFeatureList(meta.addon_keys),
event_enabled: meta.event_enabled === undefined ? true : Boolean(meta.event_enabled),
allow_custom_prompt: meta.allow_custom_prompt === undefined ? true : Boolean(meta.allow_custom_prompt),
allowed_style_keys: normalizeFeatureList(meta.allowed_style_keys),
policy_message: typeof meta.policy_message === 'string' ? meta.policy_message : null,
},
};
}
export async function getEventAiEditSummary(slug: string): Promise<AiEditUsageSummary> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/ai-edits/summary`);
const payload = await jsonOrThrow<{ data?: Record<string, unknown> }>(response, 'Failed to load AI usage summary');
const row = payload.data ?? {};
const statusCounts = row.status_counts && typeof row.status_counts === 'object' ? (row.status_counts as Record<string, unknown>) : {};
const safetyCounts = row.safety_counts && typeof row.safety_counts === 'object' ? (row.safety_counts as Record<string, unknown>) : {};
return {
event_id: Number(row.event_id ?? 0),
total: Number(row.total ?? 0),
failed_total: Number(row.failed_total ?? 0),
status_counts: Object.fromEntries(
Object.entries(statusCounts).map(([key, value]) => [key, Number(value ?? 0)])
),
safety_counts: Object.fromEntries(
Object.entries(safetyCounts).map(([key, value]) => [key, Number(value ?? 0)])
),
last_requested_at: typeof row.last_requested_at === 'string' ? row.last_requested_at : null,
};
}
export async function createEventAddonCheckout(
eventSlug: string,
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
@@ -2822,16 +3051,26 @@ export async function downloadTenantBillingReceipt(receiptUrl: string): Promise<
return response.blob();
}
export async function getTenantBillingTransactions(page = 1): Promise<{
export async function getTenantBillingTransactions(page = 1, perPage = 25): Promise<{
data: TenantBillingTransactionSummary[];
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/transactions?${params.toString()}`);
if (response.status === 404) {
return { data: [] };
return {
data: [],
meta: {
current_page: 1,
last_page: 1,
per_page: perPage,
total: 0,
},
};
}
if (!response.ok) {
@@ -2845,6 +3084,7 @@ export async function getTenantBillingTransactions(page = 1): Promise<{
return {
data: entries.map(normalizeTenantBillingTransaction),
meta: buildPagination(payload, entries.length),
};
}
@@ -2898,15 +3138,36 @@ export async function createTenantBillingPortalSession(): Promise<{ url: string
return { url };
}
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
export async function getTenantAddonHistory(options?: {
page?: number;
perPage?: number;
eventId?: number;
eventSlug?: string;
status?: TenantAddonHistoryEntry['status'];
}): Promise<{
data: TenantAddonHistoryEntry[];
meta: PaginationMeta;
meta: PaginationMeta & { scope?: TenantBillingAddonScope };
}> {
const page = options?.page ?? 1;
const perPage = options?.perPage ?? 25;
const params = new URLSearchParams({
page: String(Math.max(1, page)),
per_page: String(Math.max(1, Math.min(perPage, 100))),
});
if (options?.eventId) {
params.set('event_id', String(options.eventId));
}
if (options?.eventSlug) {
params.set('event_slug', options.eventSlug);
}
if (options?.status) {
params.set('status', options.status);
}
const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`);
if (response.status === 404) {
@@ -2916,7 +3177,19 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
};
}
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<PaginationMeta>; current_page?: number; last_page?: number; per_page?: number; total?: number }>(
const payload = await jsonOrThrow<{
data?: JsonValue[];
meta?: Partial<PaginationMeta> & {
scope?: {
type?: unknown;
event?: JsonValue | null;
};
};
current_page?: number;
last_page?: number;
per_page?: number;
total?: number;
}>(
response,
'Failed to load add-on history'
);
@@ -2924,13 +3197,34 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
const rows = Array.isArray(payload.data) ? payload.data.map((row) => normalizeTenantAddonHistoryEntry(row)) : [];
const metaSource = payload.meta ?? payload;
const meta: PaginationMeta = {
const meta: PaginationMeta & { scope?: TenantBillingAddonScope } = {
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),
};
const rawScope = payload.meta?.scope;
if (rawScope && typeof rawScope === 'object') {
const scopeEvent = rawScope.event && typeof rawScope.event === 'object' && !Array.isArray(rawScope.event)
? (rawScope.event as Record<string, unknown>)
: null;
meta.scope = {
type: rawScope.type === 'event' ? 'event' : 'tenant',
event: scopeEvent
? {
id: Number(scopeEvent.id ?? 0),
slug: typeof scopeEvent.slug === 'string' ? scopeEvent.slug : '',
name:
typeof scopeEvent.name === 'string' || typeof scopeEvent.name === 'object'
? (scopeEvent.name as TenantAddonEventSummary['name'])
: null,
}
: null,
};
}
return { data: rows, meta };
}