feat: implement AI styling foundation and billing scope rework
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user