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

@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchJsonMock = vi.fn();
vi.mock('../apiClient', () => ({
fetchJson: (...args: unknown[]) => fetchJsonMock(...args),
}));
vi.mock('../../lib/device', () => ({
getDeviceId: () => 'device-123',
}));
import { createGuestAiEdit, fetchGuestAiEditStatus, fetchGuestAiStyles } from '../aiEditsApi';
describe('aiEditsApi', () => {
beforeEach(() => {
fetchJsonMock.mockReset();
});
it('loads guest ai styles with device header', async () => {
fetchJsonMock.mockResolvedValue({
data: {
data: [{ id: 10, key: 'style-a', name: 'Style A' }],
meta: { allow_custom_prompt: false },
},
});
const payload = await fetchGuestAiStyles('token-abc');
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/ai-styles', {
headers: {
'X-Device-Id': 'device-123',
},
noStore: true,
});
expect(payload.data).toHaveLength(1);
expect(payload.data[0]?.key).toBe('style-a');
expect(payload.meta.allow_custom_prompt).toBe(false);
});
it('creates guest ai edit with json payload', async () => {
fetchJsonMock.mockResolvedValue({
data: {
duplicate: false,
data: {
id: 55,
event_id: 1,
photo_id: 9,
status: 'queued',
outputs: [],
},
},
});
const payload = await createGuestAiEdit('token-abc', 9, {
style_key: 'style-a',
idempotency_key: 'demo-key',
});
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/photos/9/ai-edits', {
method: 'POST',
headers: {
'X-Device-Id': 'device-123',
'Content-Type': 'application/json',
},
body: JSON.stringify({
style_key: 'style-a',
idempotency_key: 'demo-key',
}),
noStore: true,
});
expect(payload.data.id).toBe(55);
expect(payload.data.status).toBe('queued');
});
it('throws when status payload is malformed', async () => {
fetchJsonMock.mockResolvedValue({ data: null });
await expect(fetchGuestAiEditStatus('token-abc', 55)).rejects.toThrow('AI edit status response is invalid.');
});
});

View File

@@ -0,0 +1,142 @@
import { fetchJson } from './apiClient';
import { getDeviceId } from '../lib/device';
export type GuestAiStyle = {
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 GuestAiStylesMeta = {
required_feature?: string | null;
addon_keys?: string[] | null;
allow_custom_prompt?: boolean;
allowed_style_keys?: string[] | null;
policy_message?: string | null;
};
export type GuestAiEditOutput = {
id: number;
storage_disk?: string | null;
storage_path?: string | null;
provider_url?: string | null;
mime_type?: string | null;
width?: number | null;
height?: number | null;
is_primary?: boolean;
safety_state?: string | null;
safety_reasons?: string[];
generated_at?: string | null;
};
export type GuestAiEditRequest = {
id: number;
event_id: number;
photo_id: number;
style?: {
id: number;
key: string;
name: string;
} | null;
provider?: string | null;
provider_model?: string | null;
status: 'queued' | 'processing' | 'succeeded' | 'failed' | 'blocked' | 'canceled' | string;
safety_state?: string | null;
safety_reasons?: string[];
failure_code?: string | null;
failure_message?: string | null;
queued_at?: string | null;
started_at?: string | null;
completed_at?: string | null;
outputs: GuestAiEditOutput[];
};
export type GuestAiStylesResponse = {
data: GuestAiStyle[];
meta: GuestAiStylesMeta;
};
export type GuestAiEditEnvelope = {
message?: string;
duplicate?: boolean;
data: GuestAiEditRequest;
};
function deviceHeaders(): Record<string, string> {
return {
'X-Device-Id': getDeviceId(),
};
}
export async function fetchGuestAiStyles(eventToken: string): Promise<GuestAiStylesResponse> {
const response = await fetchJson<GuestAiStylesResponse>(
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-styles`,
{
headers: deviceHeaders(),
noStore: true,
}
);
const payload = response.data;
return {
data: Array.isArray(payload?.data) ? payload.data : [],
meta: payload?.meta && typeof payload.meta === 'object' ? payload.meta : {},
};
}
export async function createGuestAiEdit(
eventToken: string,
photoId: number,
payload: {
style_key?: string;
prompt?: string;
negative_prompt?: string;
provider_model?: string;
idempotency_key?: string;
session_id?: string;
metadata?: Record<string, unknown>;
}
): Promise<GuestAiEditEnvelope> {
const response = await fetchJson<GuestAiEditEnvelope>(
`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/ai-edits`,
{
method: 'POST',
headers: {
...deviceHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
noStore: true,
}
);
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
throw new Error('AI edit request response is invalid.');
}
return response.data;
}
export async function fetchGuestAiEditStatus(eventToken: string, requestId: number): Promise<{ data: GuestAiEditRequest }> {
const response = await fetchJson<{ data: GuestAiEditRequest }>(
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-edits/${requestId}`,
{
headers: deviceHeaders(),
noStore: true,
}
);
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
throw new Error('AI edit status response is invalid.');
}
return response.data;
}