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,154 @@
<?php
namespace App\Filament\Resources\AiStyles;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
use App\Models\AiStyle;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
class AiStyleResource extends Resource
{
protected static ?string $model = AiStyle::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-paint-brush';
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 31;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getNavigationLabel(): string
{
return 'AI Styles';
}
public static function form(Schema $schema): Schema
{
return $schema->schema([
Section::make('Style Basics')
->schema([
TextInput::make('key')
->required()
->maxLength(120)
->unique(ignoreRecord: true),
TextInput::make('name')
->required()
->maxLength(120),
TextInput::make('category')
->maxLength(50),
TextInput::make('sort')
->numeric()
->default(0)
->required(),
Toggle::make('is_active')
->default(true),
Toggle::make('is_premium')
->default(false),
Toggle::make('requires_source_image')
->default(true),
])
->columns(3),
Section::make('Provider Binding')
->schema([
Select::make('provider')
->options([
'runware' => 'runware.ai',
])
->required()
->default('runware'),
TextInput::make('provider_model')
->maxLength(120),
])
->columns(2),
Section::make('Prompts')
->schema([
Textarea::make('description')
->rows(2),
Textarea::make('prompt_template')
->rows(5),
Textarea::make('negative_prompt_template')
->rows(4),
]),
Section::make('Metadata')
->schema([
KeyValue::make('metadata')
->nullable(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('sort')
->columns([
Tables\Columns\TextColumn::make('key')
->searchable()
->copyable(),
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('provider')
->badge(),
Tables\Columns\TextColumn::make('provider_model')
->toggleable(),
Tables\Columns\IconColumn::make('is_active')
->boolean(),
Tables\Columns\IconColumn::make('is_premium')
->boolean(),
Tables\Columns\TextColumn::make('sort')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->since()
->toggleable(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active'),
Tables\Filters\TernaryFilter::make('is_premium'),
])
->actions([
Actions\EditAction::make()
->after(fn (array $data, AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
static::class
)),
Actions\DeleteAction::make()
->after(fn (AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
])
->bulkActions([
Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ManageAiStyles::route('/'),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\AiStyles\Pages;
use App\Filament\Resources\AiStyles\AiStyleResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageAiStyles extends ManageRecords
{
protected static string $resource = AiStyleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
static::class
)),
];
}
}

View File

@@ -465,6 +465,7 @@ class PackageResource extends Resource
'unlimited_sharing' => 'Unbegrenztes Teilen',
'no_watermark' => 'Kein Wasserzeichen',
'custom_branding' => 'Eigenes Branding',
'ai_styling' => 'AI-Styling',
'custom_tasks' => 'Eigene Aufgaben',
'reseller_dashboard' => 'Reseller-Dashboard',
'advanced_analytics' => 'Erweiterte Analytics',

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Models\AiEditingSetting;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class AiEditingSettingsPage extends Page
{
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $cluster = RareAdminCluster::class;
protected string $view = 'filament.super-admin.pages.ai-editing-settings-page';
protected static null|string|\UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 30;
public static function getNavigationGroup(): \UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getNavigationLabel(): string
{
return 'AI Editing Settings';
}
public bool $is_enabled = true;
public string $default_provider = 'runware';
public ?string $fallback_provider = null;
public string $runware_mode = 'live';
public bool $queue_auto_dispatch = false;
public string $queue_name = 'default';
public int $queue_max_polls = 6;
/**
* @var array<int, string>
*/
public array $blocked_terms = [];
public ?string $status_message = null;
public function mount(): void
{
$settings = AiEditingSetting::current();
$this->is_enabled = (bool) $settings->is_enabled;
$this->default_provider = (string) ($settings->default_provider ?: 'runware');
$this->fallback_provider = $settings->fallback_provider ? (string) $settings->fallback_provider : null;
$this->runware_mode = (string) ($settings->runware_mode ?: 'live');
$this->queue_auto_dispatch = (bool) $settings->queue_auto_dispatch;
$this->queue_name = (string) ($settings->queue_name ?: 'default');
$this->queue_max_polls = max(1, (int) ($settings->queue_max_polls ?: 6));
$this->blocked_terms = array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
(array) $settings->blocked_terms
)));
$this->status_message = $settings->status_message ? (string) $settings->status_message : null;
}
public function form(Schema $schema): Schema
{
return $schema->schema([
Section::make('Global Availability')
->schema([
Forms\Components\Toggle::make('is_enabled')
->label('Enable AI editing globally'),
Forms\Components\Textarea::make('status_message')
->label('Disabled message')
->maxLength(255)
->rows(2)
->helperText('Shown to guest and tenant clients when the feature is disabled.')
->nullable(),
]),
Section::make('Provider')
->schema([
Forms\Components\Select::make('default_provider')
->label('Default provider')
->options([
'runware' => 'runware.ai',
])
->required(),
Forms\Components\TextInput::make('fallback_provider')
->label('Fallback provider')
->maxLength(40)
->helperText('Reserved for provider failover.'),
Forms\Components\Select::make('runware_mode')
->label('Runware mode')
->options([
'live' => 'Live API',
'fake' => 'Fake mode (internal testing)',
])
->required(),
])
->columns(2),
Section::make('Queue Orchestration')
->schema([
Forms\Components\Toggle::make('queue_auto_dispatch')
->label('Auto-dispatch jobs after request creation'),
Forms\Components\TextInput::make('queue_name')
->label('Queue name')
->required()
->maxLength(60),
Forms\Components\TextInput::make('queue_max_polls')
->label('Max provider polls')
->numeric()
->minValue(1)
->maxValue(50)
->required(),
])
->columns(2),
Section::make('Prompt Safety')
->schema([
Forms\Components\TagsInput::make('blocked_terms')
->label('Blocked prompt terms')
->helperText('Case-insensitive term match before queue dispatch.')
->placeholder('Add blocked term'),
]),
]);
}
public function save(): void
{
$this->validate();
$settings = AiEditingSetting::query()->firstOrNew(['id' => 1]);
$settings->is_enabled = $this->is_enabled;
$settings->default_provider = $this->default_provider;
$settings->fallback_provider = $this->nullableString($this->fallback_provider);
$settings->runware_mode = $this->runware_mode;
$settings->queue_auto_dispatch = $this->queue_auto_dispatch;
$settings->queue_name = $this->queue_name;
$settings->queue_max_polls = max(1, $this->queue_max_polls);
$settings->blocked_terms = array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
$this->blocked_terms
)));
$settings->status_message = $this->nullableString($this->status_message);
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'ai_editing.settings_updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
Notification::make()
->title('AI editing settings saved.')
->success()
->send();
}
private function nullableString(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@@ -0,0 +1,468 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\GuestAiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStyleAccessService;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class EventPublicAiEditController extends BaseController
{
public function __construct(
private readonly EventJoinTokenService $joinTokenService,
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
) {}
public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
$photoModel = Photo::query()
->whereKey($photo)
->where('event_id', $event->id)
->first();
if (! $photoModel) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
if ($photoModel->status !== 'approved') {
return ApiError::response(
'photo_not_eligible',
'Photo not eligible',
'Only approved photos can be used for AI edits.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$style = $this->resolveStyleByKey($request->input('style_key'));
if ($request->filled('style_key') && ! $style) {
return ApiError::response(
'style_not_found',
'Style not found',
'The selected style is not available.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (
$style
&& (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style))
) {
return ApiError::response(
'style_not_allowed',
'Style not allowed',
$policy['policy_message'] ?? 'This style is not allowed for this event.',
Response::HTTP_UNPROCESSABLE_ENTITY,
[
'allowed_style_keys' => $policy['allowed_style_keys'],
]
);
}
$prompt = (string) ($request->input('prompt') ?: $style?->prompt_template ?: '');
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style?->negative_prompt_template ?: '');
$providerModel = $request->input('provider_model') ?: $style?->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
$sessionId = $this->normalizeOptionalString((string) $request->input('session_id', ''));
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
$request->header('X-Idempotency-Key'),
$photoModel,
$style,
$prompt,
$deviceId,
$sessionId
);
$attributes = [
'event_id' => $event->id,
'photo_id' => $photoModel->id,
'style_id' => $style?->id,
'provider' => $this->runtimeConfig->defaultProvider(),
'provider_model' => $providerModel,
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
'safety_state' => $safetyDecision->state,
'prompt' => $prompt,
'negative_prompt' => $negativePrompt,
'input_image_path' => $photoModel->file_path,
'requested_by_device_id' => $deviceId,
'requested_by_session_id' => $sessionId,
'safety_reasons' => $safetyDecision->reasonCodes,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $request->input('metadata', []),
];
$editRequest = AiEditRequest::query()->firstOrCreate(
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
$attributes
);
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
$editRequest,
$event,
$photoModel,
$style?->id,
$prompt,
$negativePrompt,
$providerModel,
$deviceId,
$sessionId
)) {
return ApiError::response(
'idempotency_conflict',
'Idempotency conflict',
'The provided idempotency key is already in use for another request.',
Response::HTTP_CONFLICT
);
}
if (
$editRequest->wasRecentlyCreated
&& ! $safetyDecision->blocked
&& $this->runtimeConfig->queueAutoDispatch()
) {
ProcessAiEditRequest::dispatch($editRequest->id)
->onQueue($this->runtimeConfig->queueName());
}
return response()->json([
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
'duplicate' => ! $editRequest->wasRecentlyCreated,
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
}
public function show(Request $request, string $token, int $requestId): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
$editRequest = AiEditRequest::query()
->with(['style', 'outputs'])
->whereKey($requestId)
->where('event_id', $event->id)
->first();
if (! $editRequest) {
return ApiError::response(
'edit_request_not_found',
'Edit request not found',
'The specified AI edit request could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
if ($editRequest->requested_by_device_id && $deviceId && $editRequest->requested_by_device_id !== $deviceId) {
return ApiError::response(
'forbidden_request_scope',
'Forbidden',
'This AI edit request belongs to another device.',
Response::HTTP_FORBIDDEN
);
}
return response()->json([
'data' => $this->serializeRequest($editRequest),
]);
}
public function styles(Request $request, string $token): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$styles = $this->eventPolicy->filterStyles(
$event,
AiStyle::query()
->where('is_active', true)
->orderBy('sort')
->orderBy('id')
->get()
);
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
return response()->json([
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
'meta' => [
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
'allow_custom_prompt' => $policy['allow_custom_prompt'],
'allowed_style_keys' => $policy['allowed_style_keys'],
'policy_message' => $policy['policy_message'],
],
]);
}
private function resolvePublishedEvent(string $token): Event|JsonResponse
{
$joinToken = $this->joinTokenService->findActiveToken($token);
if (! $joinToken) {
return ApiError::response(
'invalid_token',
'Invalid token',
'The provided event token is invalid or expired.',
Response::HTTP_NOT_FOUND
);
}
$event = Event::query()
->whereKey($joinToken->event_id)
->where('status', 'published')
->first();
if (! $event) {
return ApiError::response(
'event_not_public',
'Event not public',
'This event is not publicly accessible.',
Response::HTTP_FORBIDDEN
);
}
return $event;
}
private function resolveStyleByKey(?string $styleKey): ?AiStyle
{
$key = $this->normalizeOptionalString((string) ($styleKey ?? ''));
if (! $key) {
return null;
}
return AiStyle::query()
->where('key', $key)
->where('is_active', true)
->first();
}
private function normalizeOptionalString(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private function resolveIdempotencyKey(
mixed $bodyKey,
mixed $headerKey,
Photo $photo,
?AiStyle $style,
string $prompt,
?string $deviceId,
?string $sessionId
): string {
$candidate = $this->normalizeOptionalString((string) ($bodyKey ?: $headerKey ?: ''));
if ($candidate) {
return Str::limit($candidate, 120, '');
}
return substr(hash('sha256', implode('|', [
(string) $photo->event_id,
(string) $photo->id,
(string) ($style?->id ?? ''),
trim($prompt),
(string) ($deviceId ?? ''),
(string) ($sessionId ?? ''),
])), 0, 120);
}
private function isIdempotencyConflict(
AiEditRequest $request,
Event $event,
Photo $photo,
?int $styleId,
string $prompt,
string $negativePrompt,
?string $providerModel,
?string $deviceId,
?string $sessionId
): bool {
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
return true;
}
if ((int) ($request->style_id ?? 0) !== (int) ($styleId ?? 0)) {
return true;
}
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
return true;
}
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
return true;
}
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
return true;
}
if ($this->normalizeOptionalString($request->requested_by_device_id) !== $this->normalizeOptionalString($deviceId)) {
return true;
}
return $this->normalizeOptionalString($request->requested_by_session_id) !== $this->normalizeOptionalString($sessionId);
}
private function serializeStyle(AiStyle $style): array
{
return [
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,
'provider_model' => $style->provider_model,
'requires_source_image' => $style->requires_source_image,
'is_premium' => $style->is_premium,
'metadata' => $style->metadata ?? [],
];
}
private function serializeRequest(AiEditRequest $request): array
{
return [
'id' => $request->id,
'event_id' => $request->event_id,
'photo_id' => $request->photo_id,
'style' => $request->style ? [
'id' => $request->style->id,
'key' => $request->style->key,
'name' => $request->style->name,
] : null,
'provider' => $request->provider,
'provider_model' => $request->provider_model,
'status' => $request->status,
'safety_state' => $request->safety_state,
'safety_reasons' => $request->safety_reasons ?? [],
'failure_code' => $request->failure_code,
'failure_message' => $request->failure_message,
'queued_at' => $request->queued_at?->toIso8601String(),
'started_at' => $request->started_at?->toIso8601String(),
'completed_at' => $request->completed_at?->toIso8601String(),
'outputs' => $request->outputs->map(fn ($output) => [
'id' => $output->id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'provider_url' => $output->provider_url,
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'is_primary' => $output->is_primary,
'safety_state' => $output->safety_state,
'safety_reasons' => $output->safety_reasons ?? [],
'generated_at' => $output->generated_at?->toIso8601String(),
])->values(),
];
}
}

View File

@@ -16,6 +16,9 @@ use App\Models\GuestNotification;
use App\Models\GuestPolicySetting;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\EventTasksCacheService;
@@ -61,6 +64,9 @@ class EventPublicController extends BaseController
private readonly EventTasksCacheService $eventTasksCache,
private readonly GuestNotificationService $guestNotificationService,
private readonly PushSubscriptionService $pushSubscriptions,
private readonly AiEditingRuntimeConfig $aiEditingRuntimeConfig,
private readonly AiStylingEntitlementService $aiStylingEntitlements,
private readonly EventAiEditingPolicyService $eventAiEditingPolicy,
) {}
/**
@@ -1953,6 +1959,11 @@ class EventPublicController extends BaseController
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
$liveShowSettings = Arr::get($settings, 'live_show', []);
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
$aiStylingEntitlement = $this->aiStylingEntitlements->resolveForEvent($event);
$aiEditingPolicy = $this->eventAiEditingPolicy->resolve($event);
$aiStylingAvailable = $this->aiEditingRuntimeConfig->isEnabled()
&& (bool) $aiStylingEntitlement['allowed']
&& (bool) $aiEditingPolicy['enabled'];
$event->loadMissing('photoboothSetting');
$policy = $this->guestPolicy();
@@ -1980,6 +1991,13 @@ class EventPublicController extends BaseController
'live_show' => [
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
],
'capabilities' => [
'ai_styling' => $aiStylingAvailable,
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
],
'engagement_mode' => $engagementMode,
])->header('Cache-Control', 'no-store');
}

View File

@@ -0,0 +1,488 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\AiEditIndexRequest;
use App\Http\Requests\Tenant\AiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStyleAccessService;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class AiEditController extends Controller
{
public function __construct(
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
) {}
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$perPage = (int) $request->input('per_page', 20);
$status = (string) $request->input('status', '');
$safetyState = (string) $request->input('safety_state', '');
$query = AiEditRequest::query()
->with(['style', 'outputs'])
->where('event_id', $event->id)
->orderByDesc('created_at');
if ($status !== '') {
$query->where('status', $status);
}
if ($safetyState !== '') {
$query->where('safety_state', $safetyState);
}
$requests = $query->paginate($perPage);
return response()->json([
'data' => collect($requests->items())->map(fn (AiEditRequest $item) => $this->serializeRequest($item))->values(),
'meta' => [
'current_page' => $requests->currentPage(),
'per_page' => $requests->perPage(),
'total' => $requests->total(),
'last_page' => $requests->lastPage(),
],
]);
}
public function styles(Request $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$styles = AiStyle::query()
->where('is_active', true)
->orderBy('sort')
->orderBy('id')
->get();
$policy = $this->eventPolicy->resolve($event);
$styles = $this->eventPolicy->filterStyles($event, $styles);
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
return response()->json([
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
'meta' => [
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
'event_enabled' => $policy['enabled'],
'allow_custom_prompt' => $policy['allow_custom_prompt'],
'allowed_style_keys' => $policy['allowed_style_keys'],
'policy_message' => $policy['policy_message'],
],
]);
}
public function summary(Request $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
$statusCounts = (clone $baseQuery)
->select('status', DB::raw('count(*) as aggregate'))
->groupBy('status')
->pluck('aggregate', 'status')
->map(fn (mixed $value): int => (int) $value)
->all();
$safetyCounts = (clone $baseQuery)
->select('safety_state', DB::raw('count(*) as aggregate'))
->groupBy('safety_state')
->pluck('aggregate', 'safety_state')
->map(fn (mixed $value): int => (int) $value)
->all();
$lastRequestedAt = (clone $baseQuery)->max('created_at');
$total = array_sum($statusCounts);
return response()->json([
'data' => [
'event_id' => $event->id,
'total' => $total,
'status_counts' => $statusCounts,
'safety_counts' => $safetyCounts,
'failed_total' => (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0)),
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
],
]);
}
public function store(AiEditStoreRequest $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$photo = Photo::query()
->whereKey((int) $request->input('photo_id'))
->where('event_id', $event->id)
->first();
if (! $photo) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
$style = $this->resolveStyle($request->input('style_id'), $request->input('style_key'));
if (! $style) {
return ApiError::response(
'style_not_found',
'Style not found',
'The selected style is not available.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
return ApiError::response(
'style_not_allowed',
'Style not allowed',
$policy['policy_message'] ?? 'This style is not allowed for this event.',
Response::HTTP_UNPROCESSABLE_ENTITY,
[
'allowed_style_keys' => $policy['allowed_style_keys'],
]
);
}
$prompt = (string) ($request->input('prompt') ?: $style->prompt_template ?: '');
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style->negative_prompt_template ?: '');
$providerModel = $request->input('provider_model') ?: $style->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$requestedByUserId = $request->user()?->id;
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
$request->header('X-Idempotency-Key'),
$event,
$photo,
$style,
$prompt,
$requestedByUserId
);
$editRequest = AiEditRequest::query()->firstOrCreate(
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
[
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'requested_by_user_id' => $requestedByUserId,
'provider' => $this->runtimeConfig->defaultProvider(),
'provider_model' => $providerModel,
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
'safety_state' => $safetyDecision->state,
'prompt' => $prompt,
'negative_prompt' => $negativePrompt,
'input_image_path' => $photo->file_path,
'idempotency_key' => $idempotencyKey,
'safety_reasons' => $safetyDecision->reasonCodes,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $request->input('metadata', []),
]
);
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
$editRequest,
$event,
$photo,
$style,
$prompt,
$negativePrompt,
$providerModel,
$requestedByUserId
)) {
return ApiError::response(
'idempotency_conflict',
'Idempotency conflict',
'The provided idempotency key is already in use for another request.',
Response::HTTP_CONFLICT
);
}
if (
$editRequest->wasRecentlyCreated
&& ! $safetyDecision->blocked
&& $this->runtimeConfig->queueAutoDispatch()
) {
ProcessAiEditRequest::dispatch($editRequest->id)
->onQueue($this->runtimeConfig->queueName());
}
return response()->json([
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
'duplicate' => ! $editRequest->wasRecentlyCreated,
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
}
public function show(Request $request, string $eventSlug, int $aiEditRequest): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$editRequest = AiEditRequest::query()
->with(['style', 'outputs'])
->whereKey($aiEditRequest)
->where('event_id', $event->id)
->first();
if (! $editRequest) {
return ApiError::response(
'edit_request_not_found',
'Edit request not found',
'The specified AI edit request could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
return response()->json([
'data' => $this->serializeRequest($editRequest),
]);
}
private function resolveTenantEventOrFail(Request $request, string $eventSlug): Event
{
$tenantId = $request->attributes->get('tenant_id');
return Event::query()
->where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
}
private function resolveStyle(mixed $styleId, mixed $styleKey): ?AiStyle
{
if ($styleId !== null) {
return AiStyle::query()
->whereKey((int) $styleId)
->where('is_active', true)
->first();
}
$key = trim((string) ($styleKey ?? ''));
if ($key === '') {
return null;
}
return AiStyle::query()
->where('key', $key)
->where('is_active', true)
->first();
}
private function resolveIdempotencyKey(
mixed $bodyKey,
mixed $headerKey,
Event $event,
Photo $photo,
AiStyle $style,
string $prompt,
mixed $requestedByUserId
): string {
$candidate = trim((string) ($bodyKey ?: $headerKey ?: ''));
if ($candidate !== '') {
return Str::limit($candidate, 120, '');
}
return substr(hash('sha256', implode('|', [
(string) $event->id,
(string) $photo->id,
(string) $style->id,
trim($prompt),
(string) ($this->normalizeUserId($requestedByUserId)),
])), 0, 120);
}
private function normalizeUserId(mixed $userId): ?string
{
if (! is_int($userId) && ! is_string($userId)) {
return null;
}
$value = trim((string) $userId);
return $value !== '' ? $value : null;
}
private function normalizeOptionalString(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private function isIdempotencyConflict(
AiEditRequest $request,
Event $event,
Photo $photo,
AiStyle $style,
string $prompt,
string $negativePrompt,
?string $providerModel,
mixed $requestedByUserId
): bool {
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
return true;
}
if ((int) ($request->style_id ?? 0) !== (int) $style->id) {
return true;
}
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
return true;
}
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
return true;
}
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
return true;
}
return $this->normalizeUserId($request->requested_by_user_id) !== $this->normalizeUserId($requestedByUserId);
}
private function serializeStyle(AiStyle $style): array
{
return [
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,
'provider_model' => $style->provider_model,
'requires_source_image' => $style->requires_source_image,
'is_premium' => $style->is_premium,
'metadata' => $style->metadata ?? [],
];
}
private function serializeRequest(AiEditRequest $request): array
{
return [
'id' => $request->id,
'event_id' => $request->event_id,
'photo_id' => $request->photo_id,
'style' => $request->style ? [
'id' => $request->style->id,
'key' => $request->style->key,
'name' => $request->style->name,
] : null,
'provider' => $request->provider,
'provider_model' => $request->provider_model,
'status' => $request->status,
'safety_state' => $request->safety_state,
'safety_reasons' => $request->safety_reasons ?? [],
'failure_code' => $request->failure_code,
'failure_message' => $request->failure_message,
'queued_at' => $request->queued_at?->toIso8601String(),
'started_at' => $request->started_at?->toIso8601String(),
'completed_at' => $request->completed_at?->toIso8601String(),
'outputs' => $request->outputs->map(fn ($output) => [
'id' => $output->id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'provider_url' => $output->provider_url,
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'is_primary' => $output->is_primary,
'safety_state' => $output->safety_state,
'safety_reasons' => $output->safety_reasons ?? [],
'generated_at' => $output->generated_at?->toIso8601String(),
])->values(),
];
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\BillingAddonHistoryRequest;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Models\PackagePurchase;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
@@ -75,7 +77,7 @@ class TenantBillingController extends Controller
]);
}
public function addons(Request $request): JsonResponse
public function addons(BillingAddonHistoryRequest $request): JsonResponse
{
$tenant = $request->attributes->get('tenant');
@@ -86,12 +88,46 @@ class TenantBillingController extends Controller
], 404);
}
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
$page = max(1, (int) $request->query('page', 1));
$perPage = max(1, min((int) $request->validated('per_page', 25), 100));
$page = max(1, (int) $request->validated('page', 1));
$eventId = $request->validated('event_id');
$eventSlug = $request->validated('event_slug');
$status = $request->validated('status');
$paginator = EventPackageAddon::query()
$scopeEvent = null;
if ($eventId !== null || $eventSlug !== null) {
$scopeEventQuery = Event::query()
->where('tenant_id', $tenant->id);
if ($eventId !== null) {
$scopeEventQuery->whereKey((int) $eventId);
} elseif (is_string($eventSlug) && trim($eventSlug) !== '') {
$scopeEventQuery->where('slug', $eventSlug);
}
$scopeEvent = $scopeEventQuery->first();
if (! $scopeEvent) {
return response()->json([
'data' => [],
'message' => 'Event scope not found.',
], 404);
}
}
$query = EventPackageAddon::query()
->where('tenant_id', $tenant->id)
->with(['event:id,name,slug'])
->with(['event:id,name,slug']);
if ($scopeEvent) {
$query->where('event_id', $scopeEvent->id);
}
if (is_string($status) && $status !== '') {
$query->where('status', $status);
}
$paginator = $query
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
@@ -125,6 +161,17 @@ class TenantBillingController extends Controller
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'scope' => $scopeEvent ? [
'type' => 'event',
'event' => [
'id' => $scopeEvent->id,
'slug' => $scopeEvent->slug,
'name' => $scopeEvent->name,
],
] : [
'type' => 'tenant',
'event' => null,
],
],
]);
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class GuestAiEditStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'style_key' => ['nullable', 'string', 'max:120', 'required_without:prompt'],
'prompt' => ['nullable', 'string', 'max:2000', 'required_without:style_key'],
'negative_prompt' => ['nullable', 'string', 'max:2000'],
'provider_model' => ['nullable', 'string', 'max:120'],
'idempotency_key' => ['nullable', 'string', 'max:120'],
'session_id' => ['nullable', 'string', 'max:191'],
'metadata' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class AiEditIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['nullable', 'string', 'max:30'],
'safety_state' => ['nullable', 'string', 'max:30'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class AiEditStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'photo_id' => ['required', 'integer', 'exists:photos,id'],
'style_id' => ['nullable', 'integer', 'exists:ai_styles,id', 'required_without:style_key'],
'style_key' => ['nullable', 'string', 'max:120', 'required_without:style_id'],
'prompt' => ['nullable', 'string', 'max:2000'],
'negative_prompt' => ['nullable', 'string', 'max:2000'],
'provider_model' => ['nullable', 'string', 'max:120'],
'idempotency_key' => ['nullable', 'string', 'max:120'],
'metadata' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class BillingAddonHistoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'event_id' => ['nullable', 'integer', 'min:1'],
'event_slug' => ['nullable', 'string', 'max:191'],
'status' => ['nullable', 'in:pending,completed,failed'],
];
}
}

View File

@@ -83,6 +83,16 @@ class EventStoreRequest extends FormRequest
'settings.control_room.force_review_uploaders' => ['nullable', 'array'],
'settings.control_room.force_review_uploaders.*.device_id' => ['required', 'string', 'max:120'],
'settings.control_room.force_review_uploaders.*.label' => ['nullable', 'string', 'max:80'],
'settings.ai_editing' => ['nullable', 'array'],
'settings.ai_editing.enabled' => ['nullable', 'boolean'],
'settings.ai_editing.allow_custom_prompt' => ['nullable', 'boolean'],
'settings.ai_editing.allowed_style_keys' => ['nullable', 'array'],
'settings.ai_editing.allowed_style_keys.*' => [
'string',
'max:120',
Rule::exists('ai_styles', 'key')->where('is_active', true),
],
'settings.ai_editing.policy_message' => ['nullable', 'string', 'max:280'],
'settings.watermark' => ['nullable', 'array'],
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],

View File

@@ -3,6 +3,8 @@
namespace App\Http\Resources\Tenant;
use App\Models\WatermarkSetting;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\TenantMemberPermissions;
use App\Support\WatermarkConfigResolver;
@@ -49,6 +51,8 @@ class EventResource extends JsonResource
if ($eventPackage) {
$limitEvaluator = app()->make(PackageLimitEvaluator::class);
}
$aiStylingEntitlement = app()->make(AiStylingEntitlementService::class)->resolveForEvent($this->resource);
$aiEditingPolicy = app()->make(EventAiEditingPolicyService::class)->resolve($this->resource);
$settings['watermark_removal_allowed'] = WatermarkConfigResolver::determineRemovalAllowed($this->resource);
@@ -96,11 +100,22 @@ class EventResource extends JsonResource
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
'branding_allowed' => (bool) optional($eventPackage->package)->branding_allowed,
'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed,
'features' => optional($eventPackage->package)->features ?? [],
] : null,
'limits' => $eventPackage && $limitEvaluator
? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed())
: null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
'capabilities' => [
'ai_styling' => (bool) $aiStylingEntitlement['allowed'],
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
'ai_styling_allow_custom_prompt' => (bool) $aiEditingPolicy['allow_custom_prompt'],
'ai_styling_allowed_style_keys' => $aiEditingPolicy['allowed_style_keys'],
'ai_styling_policy_message' => $aiEditingPolicy['policy_message'],
],
'member_permissions' => $memberPermissions,
];
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Jobs;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiImageProviderManager;
use App\Services\AiEditing\AiUsageLedgerService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
class PollAiEditRequest implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/**
* @var array<int, int>
*/
public array $backoff = [20, 60, 120];
public int $timeout = 60;
public function __construct(
private readonly int $requestId,
private readonly string $providerTaskId,
private readonly int $pollAttempt = 1,
) {
$this->onQueue((string) config('ai-editing.queue.name', 'default'));
}
public function handle(
AiImageProviderManager $providers,
AiSafetyPolicyService $safetyPolicy,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
$request = AiEditRequest::query()->with('outputs')->find($this->requestId);
if (! $request || $request->status !== AiEditRequest::STATUS_PROCESSING) {
return;
}
$run = AiProviderRun::query()->create([
'request_id' => $request->id,
'provider' => $request->provider,
'attempt' => ((int) $request->providerRuns()->max('attempt')) + 1,
'provider_task_id' => $this->providerTaskId,
'status' => AiProviderRun::STATUS_RUNNING,
'started_at' => now(),
]);
$result = $providers->forProvider($request->provider)->poll($request, $this->providerTaskId);
$run->forceFill([
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
'http_status' => $result->httpStatus,
'finished_at' => $result->status === 'processing' ? null : now(),
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
'cost_usd' => $result->costUsd,
'request_payload' => $result->requestPayload,
'response_payload' => $result->responsePayload,
'error_message' => $result->failureMessage,
])->save();
if ($result->status === 'succeeded') {
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
if ($outputDecision->blocked) {
$request->forceFill([
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => $outputDecision->state,
'safety_reasons' => $outputDecision->reasonCodes,
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
'failure_message' => $outputDecision->failureMessage,
'completed_at' => now(),
])->save();
return;
}
foreach ($result->outputs as $output) {
AiEditOutput::query()->updateOrCreate(
[
'request_id' => $request->id,
'provider_asset_id' => (string) Arr::get($output, 'provider_asset_id', $this->providerTaskId),
],
[
'provider_url' => Arr::get($output, 'provider_url'),
'mime_type' => Arr::get($output, 'mime_type'),
'width' => Arr::get($output, 'width'),
'height' => Arr::get($output, 'height'),
'is_primary' => true,
'safety_state' => 'passed',
'safety_reasons' => [],
'generated_at' => now(),
'metadata' => ['provider' => $request->provider],
]
);
}
$request->forceFill([
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'safety_reasons' => [],
'failure_code' => null,
'failure_message' => null,
'completed_at' => now(),
])->save();
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
'source' => 'poll_job',
'poll_attempt' => $this->pollAttempt,
]);
return;
}
if ($result->status === 'processing') {
$maxPolls = $runtimeConfig->maxPolls();
if ($this->pollAttempt < $maxPolls) {
self::dispatch($request->id, $this->providerTaskId, $this->pollAttempt + 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
}
return;
}
$request->forceFill([
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
'safety_state' => $result->safetyState ?? $request->safety_state,
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
'failure_code' => $result->failureCode,
'failure_message' => $result->failureMessage,
'completed_at' => now(),
])->save();
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Jobs;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiImageProviderManager;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\AiUsageLedgerService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class ProcessAiEditRequest implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/**
* @var array<int, int>
*/
public array $backoff = [30, 120, 300];
public int $timeout = 90;
public function __construct(private readonly int $requestId)
{
$queue = (string) config('ai-editing.queue.name', 'default');
$this->onQueue($queue);
}
public function handle(
AiImageProviderManager $providers,
AiSafetyPolicyService $safetyPolicy,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
$request = AiEditRequest::query()->with('style')->find($this->requestId);
if (! $request) {
return;
}
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
return;
}
if ($request->status === AiEditRequest::STATUS_QUEUED) {
$request->forceFill([
'status' => AiEditRequest::STATUS_PROCESSING,
'started_at' => $request->started_at ?: now(),
])->save();
}
$attempt = ((int) $request->providerRuns()->max('attempt')) + 1;
$providerRun = AiProviderRun::query()->create([
'request_id' => $request->id,
'provider' => $request->provider,
'attempt' => $attempt,
'status' => AiProviderRun::STATUS_RUNNING,
'started_at' => now(),
]);
$result = $providers->forProvider($request->provider)->submit($request);
$this->finalizeProviderRun($providerRun, $result);
$this->applyProviderResult($request->fresh(['outputs']), $result, $safetyPolicy, $runtimeConfig, $usageLedger);
}
private function finalizeProviderRun(AiProviderRun $run, AiProviderResult $result): void
{
$run->forceFill([
'provider_task_id' => $result->providerTaskId,
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
'http_status' => $result->httpStatus,
'finished_at' => $result->status === 'processing' ? null : now(),
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
'cost_usd' => $result->costUsd,
'request_payload' => $result->requestPayload,
'response_payload' => $result->responsePayload,
'error_message' => $result->failureMessage,
])->save();
}
private function applyProviderResult(
AiEditRequest $request,
AiProviderResult $result,
AiSafetyPolicyService $safetyPolicy,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
if ($result->status === 'succeeded') {
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
if ($outputDecision->blocked) {
$request->forceFill([
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => $outputDecision->state,
'safety_reasons' => $outputDecision->reasonCodes,
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
'failure_message' => $outputDecision->failureMessage,
'completed_at' => now(),
])->save();
return;
}
DB::transaction(function () use ($request, $result): void {
foreach ($result->outputs as $output) {
AiEditOutput::query()->updateOrCreate(
[
'request_id' => $request->id,
'provider_asset_id' => (string) Arr::get($output, 'provider_asset_id', ''),
],
[
'provider_url' => Arr::get($output, 'provider_url'),
'mime_type' => Arr::get($output, 'mime_type'),
'width' => Arr::get($output, 'width'),
'height' => Arr::get($output, 'height'),
'is_primary' => true,
'safety_state' => 'passed',
'safety_reasons' => [],
'generated_at' => now(),
'metadata' => ['provider' => $request->provider],
]
);
}
$request->forceFill([
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'safety_reasons' => [],
'failure_code' => null,
'failure_message' => null,
'completed_at' => now(),
])->save();
});
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
'source' => 'process_job',
]);
return;
}
if ($result->status === 'processing') {
$request->forceFill([
'status' => AiEditRequest::STATUS_PROCESSING,
'failure_code' => null,
'failure_message' => null,
])->save();
if ($result->providerTaskId !== null && $result->providerTaskId !== '') {
PollAiEditRequest::dispatch($request->id, $result->providerTaskId, 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
}
return;
}
$request->forceFill([
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
'safety_state' => $result->safetyState ?? $request->safety_state,
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
'failure_code' => $result->failureCode,
'failure_message' => $result->failureMessage,
'completed_at' => now(),
])->save();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiEditOutput extends Model
{
use HasFactory;
protected $fillable = [
'request_id',
'photo_id',
'storage_disk',
'storage_path',
'mime_type',
'width',
'height',
'bytes',
'checksum',
'provider_asset_id',
'provider_url',
'is_primary',
'safety_state',
'safety_reasons',
'generated_at',
'metadata',
];
protected function casts(): array
{
return [
'is_primary' => 'boolean',
'safety_reasons' => 'array',
'metadata' => 'array',
'generated_at' => 'datetime',
];
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AiEditRequest extends Model
{
use HasFactory;
public const STATUS_QUEUED = 'queued';
public const STATUS_PROCESSING = 'processing';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_CANCELED = 'canceled';
protected $fillable = [
'tenant_id',
'event_id',
'photo_id',
'style_id',
'requested_by_user_id',
'provider',
'provider_model',
'status',
'safety_state',
'prompt',
'negative_prompt',
'input_image_path',
'requested_by_device_id',
'requested_by_session_id',
'idempotency_key',
'safety_reasons',
'failure_code',
'failure_message',
'queued_at',
'started_at',
'completed_at',
'expires_at',
'metadata',
];
protected function casts(): array
{
return [
'safety_reasons' => 'array',
'metadata' => 'array',
'queued_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
public function style(): BelongsTo
{
return $this->belongsTo(AiStyle::class, 'style_id');
}
public function requestedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by_user_id');
}
public function outputs(): HasMany
{
return $this->hasMany(AiEditOutput::class, 'request_id');
}
public function providerRuns(): HasMany
{
return $this->hasMany(AiProviderRun::class, 'request_id');
}
public function usageLedgers(): HasMany
{
return $this->hasMany(AiUsageLedger::class, 'request_id');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Throwable;
class AiEditingSetting extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'is_enabled' => 'boolean',
'queue_auto_dispatch' => 'boolean',
'queue_max_polls' => 'integer',
'blocked_terms' => 'array',
];
}
protected static function booted(): void
{
static::saved(fn () => static::flushCache());
static::deleted(fn () => static::flushCache());
}
public static function current(): self
{
/** @var self */
return Cache::remember('ai_editing.settings', now()->addMinutes(10), static function (): self {
try {
return static::query()->firstOrCreate(['id' => 1], static::defaults());
} catch (Throwable) {
return new static(static::defaults());
}
});
}
/**
* @return array<string, mixed>
*/
public static function defaults(): array
{
return [
'is_enabled' => true,
'default_provider' => (string) config('ai-editing.default_provider', 'runware'),
'fallback_provider' => null,
'runware_mode' => (string) config('ai-editing.providers.runware.mode', 'live'),
'queue_auto_dispatch' => (bool) config('ai-editing.queue.auto_dispatch', false),
'queue_name' => (string) config('ai-editing.queue.name', 'default'),
'queue_max_polls' => max(1, (int) config('ai-editing.queue.max_polls', 6)),
'blocked_terms' => array_values(array_filter((array) config('ai-editing.safety.prompt.blocked_terms', []))),
'status_message' => null,
];
}
public static function flushCache(): void
{
Cache::forget('ai_editing.settings');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiProviderRun extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'request_id',
'provider',
'attempt',
'provider_task_id',
'status',
'http_status',
'started_at',
'finished_at',
'duration_ms',
'cost_usd',
'tokens_input',
'tokens_output',
'request_payload',
'response_payload',
'error_message',
'metadata',
];
protected function casts(): array
{
return [
'request_payload' => 'array',
'response_payload' => 'array',
'metadata' => 'array',
'cost_usd' => 'decimal:5',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
}

44
app/Models/AiStyle.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AiStyle extends Model
{
use HasFactory;
protected $fillable = [
'key',
'name',
'category',
'description',
'prompt_template',
'negative_prompt_template',
'provider',
'provider_model',
'requires_source_image',
'is_premium',
'is_active',
'sort',
'metadata',
];
protected function casts(): array
{
return [
'requires_source_image' => 'boolean',
'is_premium' => 'boolean',
'is_active' => 'boolean',
'sort' => 'integer',
'metadata' => 'array',
];
}
public function editRequests(): HasMany
{
return $this->hasMany(AiEditRequest::class, 'style_id');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiUsageLedger extends Model
{
use HasFactory;
public const TYPE_DEBIT = 'debit';
public const TYPE_CREDIT = 'credit';
public const TYPE_REFUND = 'refund';
public const TYPE_ADJUSTMENT = 'adjustment';
protected $fillable = [
'tenant_id',
'event_id',
'request_id',
'entry_type',
'quantity',
'unit_cost_usd',
'amount_usd',
'currency',
'package_context',
'notes',
'recorded_at',
'metadata',
];
protected function casts(): array
{
return [
'unit_cost_usd' => 'decimal:5',
'amount_usd' => 'decimal:5',
'recorded_at' => 'datetime',
'metadata' => 'array',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
}

View File

@@ -88,6 +88,11 @@ class Event extends Model
return $this->hasMany(Photo::class);
}
public function aiEditRequests(): HasMany
{
return $this->hasMany(AiEditRequest::class);
}
public function taskCollections(): BelongsToMany
{
return $this->belongsToMany(

View File

@@ -149,6 +149,11 @@ class Photo extends Model
return $this->hasMany(PhotoShareLink::class);
}
public function aiEditRequests(): HasMany
{
return $this->hasMany(AiEditRequest::class);
}
public static function supportsFilenameColumn(): bool
{
return static::hasColumn('filename');

View File

@@ -171,6 +171,46 @@ class AppServiceProvider extends ServiceProvider
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
});
RateLimiter::for('ai-edit-guest-submit', function (Request $request) {
$token = (string) $request->route('token');
$deviceId = trim((string) $request->header('X-Device-Id', ''));
$scope = $deviceId !== '' ? 'device:'.$deviceId : 'ip:'.($request->ip() ?? 'unknown');
$key = 'ai-edit-guest-submit:'.$token.':'.$scope;
return [
Limit::perMinute(max(1, (int) config('ai-editing.abuse.guest_submit_per_minute', 8)))->by($key),
Limit::perHour(max(1, (int) config('ai-editing.abuse.guest_submit_per_hour', 40)))->by($key),
];
});
RateLimiter::for('ai-edit-guest-status', function (Request $request) {
$token = (string) $request->route('token');
$deviceId = trim((string) $request->header('X-Device-Id', ''));
$scope = $deviceId !== '' ? 'device:'.$deviceId : 'ip:'.($request->ip() ?? 'unknown');
$key = 'ai-edit-guest-status:'.$token.':'.$scope;
return Limit::perMinute(max(1, (int) config('ai-editing.abuse.guest_status_per_minute', 60)))->by($key);
});
RateLimiter::for('ai-edit-tenant-submit', function (Request $request) {
$tenantId = (string) ($request->attributes->get('tenant_id') ?? 'tenant');
$userId = (string) ($request->user()?->id ?? 'guest');
$key = 'ai-edit-tenant-submit:'.$tenantId.':'.$userId;
return [
Limit::perMinute(max(1, (int) config('ai-editing.abuse.tenant_submit_per_minute', 30)))->by($key),
Limit::perHour(max(1, (int) config('ai-editing.abuse.tenant_submit_per_hour', 240)))->by($key),
];
});
RateLimiter::for('ai-edit-tenant-status', function (Request $request) {
$tenantId = (string) ($request->attributes->get('tenant_id') ?? 'tenant');
$userId = (string) ($request->user()?->id ?? 'guest');
$key = 'ai-edit-tenant-status:'.$tenantId.':'.$userId;
return Limit::perMinute(max(1, (int) config('ai-editing.abuse.tenant_status_per_minute', 120)))->by($key);
});
RateLimiter::for('coupon-preview', function (Request $request) {
$code = strtoupper((string) $request->input('code'));
$identifier = ($request->ip() ?? 'unknown').($code ? ':'.$code : '');

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiEditingSetting;
class AiEditingRuntimeConfig
{
public function settings(): AiEditingSetting
{
return AiEditingSetting::current();
}
public function isEnabled(): bool
{
return (bool) $this->settings()->is_enabled;
}
public function defaultProvider(): string
{
return (string) ($this->settings()->default_provider ?: 'runware');
}
public function queueAutoDispatch(): bool
{
return (bool) $this->settings()->queue_auto_dispatch;
}
public function queueName(): string
{
$queueName = trim((string) ($this->settings()->queue_name ?: ''));
return $queueName !== '' ? $queueName : 'default';
}
public function maxPolls(): int
{
return max(1, (int) $this->settings()->queue_max_polls);
}
public function runwareMode(): string
{
$mode = trim((string) ($this->settings()->runware_mode ?: ''));
return $mode !== '' ? $mode : 'live';
}
/**
* @return array<int, string>
*/
public function blockedTerms(): array
{
return array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
(array) $this->settings()->blocked_terms
)));
}
public function statusMessage(): ?string
{
$message = trim((string) ($this->settings()->status_message ?? ''));
return $message !== '' ? $message : null;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Services\AiEditing;
use App\Services\AiEditing\Contracts\AiImageProvider;
use App\Services\AiEditing\Providers\NullAiImageProvider;
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
class AiImageProviderManager
{
public function forProvider(string $provider): AiImageProvider
{
return match ($provider) {
'runware' => app(RunwareAiImageProvider::class),
default => app(NullAiImageProvider::class),
};
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Services\AiEditing;
class AiProviderResult
{
/**
* @param array<int, array<string, mixed>> $outputs
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public function __construct(
public readonly string $status,
public readonly ?string $providerTaskId = null,
public readonly ?string $failureCode = null,
public readonly ?string $failureMessage = null,
public readonly ?string $safetyState = null,
public readonly array $safetyReasons = [],
public readonly ?float $costUsd = null,
public readonly array $outputs = [],
public readonly array $requestPayload = [],
public readonly array $responsePayload = [],
public readonly ?int $httpStatus = null,
) {}
/**
* @param array<int, array<string, mixed>> $outputs
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function succeeded(
array $outputs = [],
?float $costUsd = null,
?string $safetyState = null,
array $safetyReasons = [],
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'succeeded',
outputs: $outputs,
costUsd: $costUsd,
safetyState: $safetyState,
safetyReasons: $safetyReasons,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
/**
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function processing(
string $providerTaskId,
?float $costUsd = null,
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'processing',
providerTaskId: $providerTaskId,
costUsd: $costUsd,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
/**
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function failed(
string $failureCode,
string $failureMessage,
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'failed',
failureCode: $failureCode,
failureMessage: $failureMessage,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
/**
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function blocked(
string $failureCode,
string $failureMessage,
?string $safetyState = null,
array $safetyReasons = [],
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'blocked',
failureCode: $failureCode,
failureMessage: $failureMessage,
safetyState: $safetyState,
safetyReasons: $safetyReasons,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\EventPackage;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class AiStyleAccessService
{
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
public function canUseStyle(Event $event, AiStyle $style): bool
{
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return false;
}
$allowedSources = $this->allowedSources($style);
if (! in_array((string) $entitlement['granted_by'], $allowedSources, true)) {
return false;
}
$requiredPackageFeatures = $this->requiredPackageFeatures($style);
if ($requiredPackageFeatures === []) {
return true;
}
$packageFeatures = $this->resolveEventPackageFeatures($event);
foreach ($requiredPackageFeatures as $requiredFeature) {
if (! in_array($requiredFeature, $packageFeatures, true)) {
return false;
}
}
return true;
}
/**
* @param Collection<int, AiStyle> $styles
* @return Collection<int, AiStyle>
*/
public function filterStylesForEvent(Event $event, Collection $styles): Collection
{
return $styles
->filter(fn (AiStyle $style): bool => $this->canUseStyle($event, $style))
->values();
}
/**
* @return array<int, string>
*/
private function allowedSources(AiStyle $style): array
{
$metadataSources = $this->normalizeStringList(Arr::get($style->metadata ?? [], 'entitlements.allowed_sources', []));
if ($metadataSources !== []) {
return $metadataSources;
}
if (is_bool(Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon'))) {
return Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon')
? ['package', 'addon']
: ['package'];
}
return $style->is_premium ? ['package'] : ['package', 'addon'];
}
/**
* @return array<int, string>
*/
private function requiredPackageFeatures(AiStyle $style): array
{
return $this->normalizeStringList(
Arr::get($style->metadata ?? [], 'entitlements.required_package_features', [])
);
}
/**
* @return array<int, string>
*/
private function resolveEventPackageFeatures(Event $event): array
{
$eventPackage = $event->relationLoaded('eventPackage') && $event->eventPackage
? $event->eventPackage
: $event->eventPackage()->with('package')->first();
if (! $eventPackage instanceof EventPackage) {
return [];
}
$package = $eventPackage->relationLoaded('package') ? $eventPackage->package : $eventPackage->package()->first();
return $this->normalizeFeatureList($package?->features);
}
/**
* @return array<int, string>
*/
private function normalizeFeatureList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
if (! is_array($value)) {
return [];
}
if (array_is_list($value)) {
return $this->normalizeStringList($value);
}
return $this->normalizeStringList(array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled)));
}
/**
* @return array<int, string>
*/
private function normalizeStringList(array $values): array
{
return array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$values
))));
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Services\AiEditing;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class AiStylingEntitlementService
{
public function packageFeatureKey(): string
{
$featureKey = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling'));
return $featureKey !== '' ? $featureKey : 'ai_styling';
}
/**
* @return array<int, string>
*/
public function addonKeys(): array
{
return array_values(array_filter(array_map(
static fn (mixed $key): string => trim((string) $key),
(array) config('ai-editing.entitlements.addon_keys', ['ai_styling_unlock'])
)));
}
public function lockedMessage(): string
{
$message = trim((string) config('ai-editing.entitlements.locked_message', ''));
if ($message !== '') {
return $message;
}
return 'AI editing requires the Premium package or the AI Styling add-on.';
}
/**
* @return array{
* allowed: bool,
* granted_by: 'package'|'addon'|null,
* required_feature: string,
* addon_keys: array<int, string>
* }
*/
public function resolveForEvent(Event $event): array
{
$requiredFeature = $this->packageFeatureKey();
$addonKeys = $this->addonKeys();
$eventPackage = $this->resolveEventPackage($event);
if (! $eventPackage) {
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->packageGrantsAccess($eventPackage->package, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'package',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->addonGrantsAccess($eventPackage, $addonKeys, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'addon',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
public function hasAccessForEvent(Event $event): bool
{
return (bool) $this->resolveForEvent($event)['allowed'];
}
private function resolveEventPackage(Event $event): ?EventPackage
{
$event->loadMissing('eventPackage.package', 'eventPackage.addons');
if ($event->eventPackage) {
return $event->eventPackage;
}
return $event->eventPackages()
->with(['package', 'addons'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
}
private function packageGrantsAccess(?Package $package, string $requiredFeature): bool
{
if (! $package) {
return false;
}
return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true);
}
/**
* @param array<int, string> $addonKeys
*/
private function addonGrantsAccess(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool
{
$addons = $eventPackage->relationLoaded('addons')
? $eventPackage->addons
: $eventPackage->addons()
->where('status', 'completed')
->get();
return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool {
if (! $this->addonIsActive($addon)) {
return false;
}
if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) {
return true;
}
$metadataFeatures = $this->normalizeFeatureList(
Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', []))
);
return in_array($requiredFeature, $metadataFeatures, true);
});
}
private function addonIsActive(EventPackageAddon $addon): bool
{
if ($addon->status !== 'completed') {
return false;
}
$expiryCandidates = [
Arr::get($addon->metadata ?? [], 'entitlements.expires_at'),
Arr::get($addon->metadata ?? [], 'expires_at'),
Arr::get($addon->metadata ?? [], 'valid_until'),
];
foreach ($expiryCandidates as $candidate) {
if (! is_string($candidate) || trim($candidate) === '') {
continue;
}
try {
$expiresAt = CarbonImmutable::parse($candidate);
} catch (\Throwable) {
continue;
}
if ($expiresAt->isPast()) {
return false;
}
}
return true;
}
/**
* @return array<int, string>
*/
private function normalizeFeatureList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
if (! is_array($value)) {
return [];
}
if (array_is_list($value)) {
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$value
)));
}
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled))
)));
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiEditRequest;
use App\Models\AiUsageLedger;
use App\Models\Event;
use Illuminate\Support\Facades\DB;
class AiUsageLedgerService
{
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
/**
* @param array<string, mixed> $metadata
*/
public function recordDebitForRequest(AiEditRequest $request, ?float $costUsd = null, array $metadata = []): AiUsageLedger
{
return DB::transaction(function () use ($request, $costUsd, $metadata): AiUsageLedger {
$lockedRequest = AiEditRequest::query()
->whereKey($request->id)
->lockForUpdate()
->firstOrFail();
$existing = AiUsageLedger::query()
->where('request_id', $lockedRequest->id)
->where('entry_type', AiUsageLedger::TYPE_DEBIT)
->first();
if ($existing) {
return $existing;
}
$resolvedCost = $costUsd;
if ($resolvedCost === null || $resolvedCost < 0) {
$resolvedCost = (float) config('ai-editing.billing.default_unit_cost_usd', 0.01);
}
$event = Event::query()->find($lockedRequest->event_id);
$entitlement = $event ? $this->entitlements->resolveForEvent($event) : [
'allowed' => false,
'granted_by' => null,
];
$packageContext = $entitlement['granted_by'] === 'package'
? 'package_included'
: ($entitlement['granted_by'] === 'addon' ? 'addon_unlock' : 'unentitled');
return AiUsageLedger::query()->create([
'tenant_id' => $lockedRequest->tenant_id,
'event_id' => $lockedRequest->event_id,
'request_id' => $lockedRequest->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => $resolvedCost,
'amount_usd' => $resolvedCost,
'currency' => 'USD',
'package_context' => $packageContext,
'recorded_at' => now(),
'metadata' => array_merge([
'provider' => $lockedRequest->provider,
'provider_model' => $lockedRequest->provider_model,
'granted_by' => $entitlement['granted_by'],
], $metadata),
]);
});
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Services\AiEditing\Contracts;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiProviderResult;
interface AiImageProvider
{
public function submit(AiEditRequest $request): AiProviderResult;
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult;
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiStyle;
use App\Models\Event;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class EventAiEditingPolicyService
{
/**
* @return array{
* enabled: bool,
* allow_custom_prompt: bool,
* allowed_style_keys: array<int, string>,
* policy_message: ?string
* }
*/
public function resolve(Event $event): array
{
$settings = is_array($event->settings) ? $event->settings : [];
$aiSettings = Arr::get($settings, 'ai_editing', []);
$aiSettings = is_array($aiSettings) ? $aiSettings : [];
$enabled = array_key_exists('enabled', $aiSettings)
? (bool) $aiSettings['enabled']
: true;
$allowCustomPrompt = array_key_exists('allow_custom_prompt', $aiSettings)
? (bool) $aiSettings['allow_custom_prompt']
: true;
$allowedStyleKeys = collect($aiSettings['allowed_style_keys'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->unique()
->values()
->all();
$policyMessage = trim((string) ($aiSettings['policy_message'] ?? ''));
return [
'enabled' => $enabled,
'allow_custom_prompt' => $allowCustomPrompt,
'allowed_style_keys' => $allowedStyleKeys,
'policy_message' => $policyMessage !== '' ? $policyMessage : null,
];
}
public function isEnabled(Event $event): bool
{
return (bool) $this->resolve($event)['enabled'];
}
public function isStyleAllowed(Event $event, ?AiStyle $style): bool
{
$policy = $this->resolve($event);
if ($style === null) {
return (bool) $policy['allow_custom_prompt'];
}
/** @var array<int, string> $allowedStyleKeys */
$allowedStyleKeys = $policy['allowed_style_keys'];
if ($allowedStyleKeys === []) {
return true;
}
return in_array($style->key, $allowedStyleKeys, true);
}
/**
* @param Collection<int, AiStyle> $styles
* @return Collection<int, AiStyle>
*/
public function filterStyles(Event $event, Collection $styles): Collection
{
$policy = $this->resolve($event);
/** @var array<int, string> $allowedStyleKeys */
$allowedStyleKeys = $policy['allowed_style_keys'];
if ($allowedStyleKeys === []) {
return $styles->values();
}
return $styles
->filter(fn (AiStyle $style): bool => in_array($style->key, $allowedStyleKeys, true))
->values();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Services\AiEditing\Providers;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\Contracts\AiImageProvider;
class NullAiImageProvider implements AiImageProvider
{
public function submit(AiEditRequest $request): AiProviderResult
{
return AiProviderResult::failed(
'provider_not_supported',
sprintf('The AI provider "%s" is not supported.', $request->provider)
);
}
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult
{
return AiProviderResult::failed(
'provider_not_supported',
sprintf('The AI provider "%s" is not supported.', $request->provider)
);
}
}

View File

@@ -0,0 +1,287 @@
<?php
namespace App\Services\AiEditing\Providers;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\Contracts\AiImageProvider;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
class RunwareAiImageProvider implements AiImageProvider
{
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
public function submit(AiEditRequest $request): AiProviderResult
{
if ($this->isFakeMode()) {
return $this->fakeResult($request);
}
$apiKey = $this->apiKey();
if (! $apiKey) {
return AiProviderResult::failed(
'provider_not_configured',
'Runware API key is not configured.'
);
}
$payload = [
[
'taskType' => 'imageInference',
'taskUUID' => (string) Str::uuid(),
'positivePrompt' => (string) ($request->prompt ?? ''),
'negativePrompt' => (string) ($request->negative_prompt ?? ''),
'outputType' => 'URL',
'outputFormat' => 'JPG',
'includeCost' => true,
'safety' => [
'checkContent' => true,
],
],
];
if (is_string($request->provider_model) && $request->provider_model !== '') {
$payload[0]['model'] = $request->provider_model;
}
if (is_string($request->input_image_path) && $request->input_image_path !== '') {
$payload[0]['seedImage'] = $request->input_image_path;
}
try {
$response = Http::withToken($apiKey)
->acceptJson()
->timeout((int) config('services.runware.timeout', 90))
->post($this->baseUrl(), $payload);
$body = (array) $response->json();
$data = Arr::first((array) ($body['data'] ?? []), []);
$providerTaskId = (string) ($data['taskUUID'] ?? '');
$status = strtolower((string) ($data['status'] ?? ''));
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
if (is_string($imageUrl) && $imageUrl !== '') {
if ($providerNsfw) {
return AiProviderResult::blocked(
failureCode: 'provider_nsfw_content',
failureMessage: 'Provider flagged generated content as unsafe.',
safetyState: 'blocked',
safetyReasons: ['provider_nsfw_content'],
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
return AiProviderResult::succeeded(
outputs: [[
'provider_url' => $imageUrl,
'provider_asset_id' => $providerTaskId !== '' ? $providerTaskId : null,
'mime_type' => 'image/jpeg',
]],
costUsd: $cost,
safetyState: 'passed',
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
if ($providerTaskId !== '' || $status === 'processing') {
return AiProviderResult::processing(
providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(),
costUsd: $cost,
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
return AiProviderResult::failed(
'provider_unexpected_response',
'Runware returned an unexpected response format.',
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
} catch (Throwable $exception) {
return AiProviderResult::failed(
'provider_exception',
$exception->getMessage(),
requestPayload: ['tasks' => $payload],
);
}
}
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult
{
if ($this->isFakeMode()) {
return $this->fakeResult($request, $providerTaskId);
}
$apiKey = $this->apiKey();
if (! $apiKey) {
return AiProviderResult::failed(
'provider_not_configured',
'Runware API key is not configured.'
);
}
$payload = [[
'taskType' => 'getResponse',
'taskUUID' => $providerTaskId,
'includeCost' => true,
]];
try {
$response = Http::withToken($apiKey)
->acceptJson()
->timeout((int) config('services.runware.timeout', 90))
->post($this->baseUrl(), $payload);
$body = (array) $response->json();
$data = Arr::first((array) ($body['data'] ?? []), []);
$status = strtolower((string) ($data['status'] ?? ''));
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
if (is_string($imageUrl) && $imageUrl !== '') {
if ($providerNsfw) {
return AiProviderResult::blocked(
failureCode: 'provider_nsfw_content',
failureMessage: 'Provider flagged generated content as unsafe.',
safetyState: 'blocked',
safetyReasons: ['provider_nsfw_content'],
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
return AiProviderResult::succeeded(
outputs: [[
'provider_url' => $imageUrl,
'provider_asset_id' => $providerTaskId,
'mime_type' => 'image/jpeg',
]],
costUsd: $cost,
safetyState: 'passed',
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
if ($status === 'processing' || $status === '') {
return AiProviderResult::processing(
providerTaskId: $providerTaskId,
costUsd: $cost,
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
if (in_array($status, ['failed', 'error'], true)) {
return AiProviderResult::failed(
'provider_failed',
(string) ($data['errorMessage'] ?? 'Runware reported a failed job.'),
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
}
return AiProviderResult::failed(
'provider_unexpected_response',
'Runware returned an unexpected poll response format.',
requestPayload: ['tasks' => $payload],
responsePayload: $body,
httpStatus: $response->status(),
);
} catch (Throwable $exception) {
return AiProviderResult::failed(
'provider_exception',
$exception->getMessage(),
requestPayload: ['tasks' => $payload],
);
}
}
private function fakeResult(AiEditRequest $request, ?string $taskId = null): AiProviderResult
{
$resolvedTaskId = $taskId ?: 'runware-fake-'.Str::uuid()->toString();
$fakeNsfw = (bool) Arr::get($request->metadata ?? [], 'fake_nsfw', false);
return AiProviderResult::succeeded(
outputs: [[
'provider_url' => sprintf('https://cdn.example.invalid/ai/%s.jpg', $resolvedTaskId),
'provider_asset_id' => $resolvedTaskId,
'mime_type' => 'image/jpeg',
'width' => 1024,
'height' => 1024,
]],
costUsd: 0.01,
safetyState: $fakeNsfw ? 'blocked' : 'passed',
safetyReasons: $fakeNsfw ? ['provider_nsfw_content'] : [],
requestPayload: [
'prompt' => $request->prompt,
'provider_model' => $request->provider_model,
'task_id' => $resolvedTaskId,
],
responsePayload: [
'mode' => 'fake',
'status' => 'succeeded',
'data' => [
[
'taskUUID' => $resolvedTaskId,
'NSFWContent' => $fakeNsfw,
],
],
]
);
}
private function isFakeMode(): bool
{
return $this->runtimeConfig->runwareMode() === 'fake';
}
private function apiKey(): ?string
{
$apiKey = config('services.runware.api_key');
return is_string($apiKey) && $apiKey !== '' ? $apiKey : null;
}
private function baseUrl(): string
{
$base = (string) config('services.runware.base_url', 'https://api.runware.ai/v1');
return rtrim($base, '/');
}
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value === 1;
}
if (is_string($value)) {
return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true);
}
return false;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services\AiEditing\Safety;
class AiSafetyDecision
{
/**
* @param array<int, string> $reasonCodes
*/
public function __construct(
public readonly bool $blocked,
public readonly string $state,
public readonly array $reasonCodes = [],
public readonly ?string $failureCode = null,
public readonly ?string $failureMessage = null,
) {}
public static function passed(): self
{
return new self(
blocked: false,
state: 'passed',
);
}
/**
* @param array<int, string> $reasonCodes
*/
public static function blocked(array $reasonCodes, string $failureCode, string $failureMessage): self
{
return new self(
blocked: true,
state: 'blocked',
reasonCodes: array_values(array_unique($reasonCodes)),
failureCode: $failureCode,
failureMessage: $failureMessage,
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Services\AiEditing\Safety;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiProviderResult;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class AiSafetyPolicyService
{
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
public function evaluatePrompt(?string $prompt, ?string $negativePrompt): AiSafetyDecision
{
$blockedTerms = array_filter(array_map(
static fn (mixed $term): string => Str::lower(trim((string) $term)),
$this->runtimeConfig->blockedTerms()
));
if ($blockedTerms === []) {
return AiSafetyDecision::passed();
}
$fullPrompt = Str::lower(trim(sprintf('%s %s', (string) $prompt, (string) $negativePrompt)));
if ($fullPrompt === '') {
return AiSafetyDecision::passed();
}
$matched = [];
foreach ($blockedTerms as $term) {
if ($term !== '' && str_contains($fullPrompt, $term)) {
$matched[] = 'prompt_blocked_term';
}
}
if ($matched === []) {
return AiSafetyDecision::passed();
}
return AiSafetyDecision::blocked(
reasonCodes: $matched,
failureCode: 'prompt_policy_blocked',
failureMessage: 'The provided prompt violates the AI editing safety policy.'
);
}
public function evaluateProviderOutput(AiProviderResult $result): AiSafetyDecision
{
if ($result->status === 'blocked') {
return AiSafetyDecision::blocked(
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_blocked'],
failureCode: $result->failureCode ?: 'output_policy_blocked',
failureMessage: $result->failureMessage ?: 'The generated output was blocked by safety policy.'
);
}
if ($result->safetyState === 'blocked') {
return AiSafetyDecision::blocked(
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_nsfw_content'],
failureCode: 'output_policy_blocked',
failureMessage: 'The generated output was blocked by safety policy.'
);
}
$payloadItems = (array) Arr::get($result->responsePayload, 'data', []);
foreach ($payloadItems as $item) {
if (! is_array($item)) {
continue;
}
if ($this->toBool(Arr::get($item, 'NSFWContent')) || $this->toBool(Arr::get($item, 'nsfwContent'))) {
return AiSafetyDecision::blocked(
reasonCodes: ['provider_nsfw_content'],
failureCode: 'output_policy_blocked',
failureMessage: 'The generated output was blocked by safety policy.'
);
}
}
return AiSafetyDecision::passed();
}
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value === 1;
}
if (is_string($value)) {
return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true);
}
return false;
}
}

48
config/ai-editing.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
return [
'default_provider' => env('AI_EDITING_DEFAULT_PROVIDER', 'runware'),
'entitlements' => [
'package_feature' => env('AI_EDITING_PACKAGE_FEATURE', 'ai_styling'),
'addon_keys' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('AI_EDITING_ADDON_KEYS', 'ai_styling_unlock'))
))),
'locked_message' => env(
'AI_EDITING_LOCKED_MESSAGE',
'AI editing requires the Premium package or the AI Styling add-on.'
),
],
'safety' => [
'prompt' => [
'blocked_terms' => array_filter(array_map('trim', explode(',', (string) env('AI_EDITING_BLOCKED_TERMS', '')))),
],
],
'abuse' => [
'guest_submit_per_minute' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_MINUTE', 8),
'guest_submit_per_hour' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_HOUR', 40),
'guest_status_per_minute' => (int) env('AI_EDITING_GUEST_STATUS_PER_MINUTE', 60),
'tenant_submit_per_minute' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_MINUTE', 30),
'tenant_submit_per_hour' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_HOUR', 240),
'tenant_status_per_minute' => (int) env('AI_EDITING_TENANT_STATUS_PER_MINUTE', 120),
],
'queue' => [
'name' => env('AI_EDITING_QUEUE', 'default'),
'auto_dispatch' => env('AI_EDITING_AUTO_DISPATCH', false),
'max_polls' => (int) env('AI_EDITING_MAX_POLLS', 6),
],
'billing' => [
'default_unit_cost_usd' => (float) env('AI_EDITING_DEFAULT_UNIT_COST_USD', 0.01),
],
'providers' => [
'runware' => [
'mode' => env('AI_EDITING_RUNWARE_MODE', 'live'),
],
],
];

View File

@@ -32,4 +32,11 @@ return [
'extra_gallery_days' => 30,
],
],
'ai_styling_unlock' => [
'label' => 'AI Styling Add-on',
'variant_id' => env('LEMONSQUEEZY_ADDON_AI_STYLING_VARIANT_ID'),
'price' => (float) env('ADDON_AI_STYLING_PRICE', 9.00),
'currency' => 'EUR',
'increments' => [],
],
];

View File

@@ -79,4 +79,10 @@ return [
'queue' => env('REVENUECAT_WEBHOOK_QUEUE', 'webhooks'),
],
'runware' => [
'api_key' => env('RUNWARE_API_KEY'),
'base_url' => env('RUNWARE_BASE_URL', 'https://api.runware.ai/v1'),
'timeout' => (int) env('RUNWARE_TIMEOUT', 90),
],
];

View File

@@ -0,0 +1,146 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ai_styles', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->string('name', 120);
$table->string('category', 50)->nullable();
$table->text('description')->nullable();
$table->text('prompt_template')->nullable();
$table->text('negative_prompt_template')->nullable();
$table->string('provider', 40)->default('runware');
$table->string('provider_model', 120)->nullable();
$table->boolean('requires_source_image')->default(true);
$table->boolean('is_premium')->default(false);
$table->boolean('is_active')->default(true);
$table->unsignedSmallInteger('sort')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['is_active', 'sort']);
$table->index(['provider', 'is_active']);
});
Schema::create('ai_edit_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
$table->foreignId('photo_id')->constrained()->cascadeOnDelete();
$table->foreignId('style_id')->nullable()->constrained('ai_styles')->nullOnDelete();
$table->foreignId('requested_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('provider', 40)->default('runware');
$table->string('provider_model', 120)->nullable();
$table->string('status', 30)->default('queued');
$table->string('safety_state', 30)->default('pending');
$table->text('prompt')->nullable();
$table->text('negative_prompt')->nullable();
$table->string('input_image_path')->nullable();
$table->string('requested_by_device_id', 191)->nullable();
$table->string('requested_by_session_id', 191)->nullable();
$table->string('idempotency_key', 120);
$table->json('safety_reasons')->nullable();
$table->string('failure_code', 80)->nullable();
$table->string('failure_message', 500)->nullable();
$table->timestamp('queued_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'idempotency_key']);
$table->index(['tenant_id', 'event_id', 'status']);
$table->index(['event_id', 'photo_id']);
$table->index(['provider', 'status']);
$table->index('queued_at');
});
Schema::create('ai_edit_outputs', function (Blueprint $table) {
$table->id();
$table->foreignId('request_id')->constrained('ai_edit_requests')->cascadeOnDelete();
$table->foreignId('photo_id')->nullable()->constrained()->nullOnDelete();
$table->string('storage_disk', 40)->nullable();
$table->string('storage_path')->nullable();
$table->string('mime_type', 80)->nullable();
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->unsignedBigInteger('bytes')->nullable();
$table->string('checksum', 128)->nullable();
$table->string('provider_asset_id', 191)->nullable();
$table->text('provider_url')->nullable();
$table->boolean('is_primary')->default(true);
$table->string('safety_state', 30)->default('pending');
$table->json('safety_reasons')->nullable();
$table->timestamp('generated_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['request_id', 'is_primary']);
$table->index(['safety_state', 'generated_at']);
$table->index('provider_asset_id');
});
Schema::create('ai_provider_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('request_id')->constrained('ai_edit_requests')->cascadeOnDelete();
$table->string('provider', 40);
$table->unsignedSmallInteger('attempt')->default(1);
$table->string('provider_task_id', 191)->nullable();
$table->string('status', 30)->default('pending');
$table->unsignedSmallInteger('http_status')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->unsignedInteger('duration_ms')->nullable();
$table->decimal('cost_usd', 12, 5)->nullable();
$table->unsignedInteger('tokens_input')->nullable();
$table->unsignedInteger('tokens_output')->nullable();
$table->json('request_payload')->nullable();
$table->json('response_payload')->nullable();
$table->string('error_message', 500)->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['request_id', 'attempt']);
$table->index(['provider', 'provider_task_id']);
$table->index(['status', 'started_at']);
});
Schema::create('ai_usage_ledgers', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('event_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('request_id')->nullable()->constrained('ai_edit_requests')->nullOnDelete();
$table->string('entry_type', 30);
$table->integer('quantity')->default(1);
$table->decimal('unit_cost_usd', 12, 5)->default(0);
$table->decimal('amount_usd', 12, 5)->default(0);
$table->string('currency', 3)->default('USD');
$table->string('package_context', 50)->nullable();
$table->string('notes', 500)->nullable();
$table->timestamp('recorded_at');
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'recorded_at']);
$table->index(['tenant_id', 'event_id']);
$table->index(['entry_type', 'recorded_at']);
});
}
public function down(): void
{
Schema::dropIfExists('ai_usage_ledgers');
Schema::dropIfExists('ai_provider_runs');
Schema::dropIfExists('ai_edit_outputs');
Schema::dropIfExists('ai_edit_requests');
Schema::dropIfExists('ai_styles');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ai_editing_settings', function (Blueprint $table) {
$table->id();
$table->boolean('is_enabled')->default(true);
$table->string('default_provider', 40)->default('runware');
$table->string('fallback_provider', 40)->nullable();
$table->string('runware_mode', 20)->default('live');
$table->boolean('queue_auto_dispatch')->default(false);
$table->string('queue_name', 60)->default('default');
$table->unsignedSmallInteger('queue_max_polls')->default(6);
$table->json('blocked_terms')->nullable();
$table->string('status_message', 255)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ai_editing_settings');
}
};

View File

@@ -131,6 +131,22 @@ class PackageAddonSeeder extends Seeder
'sort' => 41,
'metadata' => ['price_eur' => 38],
],
[
'key' => 'ai_styling_unlock',
'label' => 'AI Styling Add-on',
'price_id' => null,
'extra_photos' => 0,
'extra_guests' => 0,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 42,
'metadata' => [
'price_eur' => 9,
'entitlements' => [
'features' => ['ai_styling'],
],
],
],
];
foreach ($addons as $addon) {

View File

@@ -97,7 +97,7 @@ TEXT,
'max_tasks' => 200,
'watermark_allowed' => true,
'branding_allowed' => true,
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support', 'ai_styling'],
'lemonsqueezy_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
'lemonsqueezy_variant_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
'description' => <<<'TEXT'

View File

@@ -161,6 +161,7 @@
"feature_no_watermark": "Kein Wasserzeichen",
"feature_custom_tasks": "Benutzerdefinierte Tasks",
"feature_advanced_analytics": "Erweiterte Analytics",
"feature_ai_styling": "AI-Styling",
"feature_priority_support": "Priorisierter Support",
"feature_limited_sharing": "Begrenztes Teilen",
"feature_no_branding": "Kein Branding",

View File

@@ -148,6 +148,7 @@
"feature_no_watermark": "No Watermark",
"feature_custom_tasks": "Custom Tasks",
"feature_advanced_analytics": "Advanced Analytics",
"feature_ai_styling": "AI Styling",
"feature_priority_support": "Priority Support",
"feature_limited_sharing": "Limited Sharing",
"feature_no_branding": "No Branding",

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 };
}

View File

@@ -85,15 +85,28 @@
"lemonsqueezy_voided": "Die PayPal-Zahlung wurde storniert."
},
"sections": {
"currentEvent": {
"title": "Aktuelles Event",
"hint": "Hier siehst du, was für dein aktuell ausgewähltes Event aktiv ist.",
"empty": "Wähle ein Event aus, um eventbezogene Pakete und Add-ons zu sehen.",
"eventLabel": "Ausgewähltes Event",
"packageActive": "Für dieses Event aktiv",
"packageExpires": "Galerie aktiv bis {{date}}",
"noPackage": "Für dieses Event ist derzeit kein Paket zugewiesen.",
"addonsLabel": "Add-ons für dieses Event",
"noAddons": "Für dieses Event wurden noch keine Add-ons gekauft.",
"eventAddonSource": "Quelle: Event-Paket"
},
"invoices": {
"title": "Rechnungen & Zahlungen",
"hint": "Zahlungen prüfen und Belege herunterladen.",
"empty": "Keine Zahlungen gefunden."
},
"addOns": {
"title": "Zusatzpakete",
"hint": "Zusatzkontingente je Event im Blick behalten.",
"empty": "Keine Zusatzpakete gebucht."
"title": "Add-on-Kaufverlauf",
"hint": "Verlauf aller Add-on-Käufe über alle Events. Ein Eintrag hier ist nicht automatisch für das aktuell ausgewählte Event aktiv.",
"empty": "Keine Add-ons gebucht.",
"otherEventNotice": "Für ein anderes Event gekauft"
},
"overview": {
"title": "Paketübersicht",
@@ -125,12 +138,13 @@
}
},
"packages": {
"title": "Pakete",
"hint": "Aktives Paket, Limits und Historie auf einen Blick.",
"title": "Paketverlauf (alle Events)",
"hint": "Alle gekauften Pakete über alle Events hinweg.",
"description": "Übersicht über aktive und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
"statusActive": "Aktiv",
"statusActiveTenant": "Derzeit aktiv",
"statusInactive": "Inaktiv",
"used": "Genutzte Events",
"available": "Verfügbar",
@@ -2392,6 +2406,7 @@
"photo_likes_enabled": "Foto-Likes",
"event_checklist": "Event-Checkliste",
"advanced_analytics": "Erweiterte Statistiken",
"ai_styling": "AI-Styling",
"branding_allowed": "Branding",
"watermark_allowed": "Wasserzeichen",
"watermark_base": "Fotospiel-Wasserzeichen",
@@ -2586,7 +2601,47 @@
"saveFailed": "Automatik-Einstellungen konnten nicht gespeichert werden."
},
"emptyModeration": "Keine Uploads passen zu diesem Filter.",
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange.",
"aiAddon": {
"title": "AI Magic Edits",
"body": "AI-Styling bleibt verborgen, bis dieses Event berechtigt ist. Schalte es per Add-on frei oder nutze Premium.",
"buyAction": "AI-Styling-Add-on kaufen",
"upgradeAction": "Premium-Paket ansehen"
},
"aiSettings": {
"title": "AI-Styling-Steuerung",
"subtitle": "Aktiviere AI-Edits pro Event, steuere erlaubte Presets und überwache Nutzung/Fehler.",
"loadFailed": "AI-Einstellungen konnten nicht geladen werden.",
"saveFailed": "AI-Einstellungen konnten nicht gespeichert werden.",
"saved": "AI-Einstellungen gespeichert.",
"enabled": {
"label": "AI-Edits für dieses Event aktivieren",
"hint": "Wenn deaktiviert, werden Guest- und Admin-AI-Anfragen für dieses Event blockiert."
},
"customPrompt": {
"label": "Freie Prompts erlauben",
"hint": "Wenn deaktiviert, müssen Nutzer ein erlaubtes Preset wählen."
},
"policyMessage": {
"label": "Policy-Hinweis",
"hint": "Wird Nutzern angezeigt, wenn AI-Edits deaktiviert sind oder ein Stil blockiert ist.",
"placeholder": "Optionaler Hinweis für Gäste/Admins"
},
"styles": {
"label": "Erlaubte AI-Stile",
"hint": "Keine Auswahl bedeutet: alle aktiven Stile sind erlaubt.",
"empty": "Keine aktiven AI-Stile gefunden.",
"clear": "Alle Stile erlauben"
},
"usage": {
"title": "Nutzungsübersicht",
"total": "Gesamt",
"succeeded": "Erfolgreich",
"failed": "Fehlgeschlagen",
"lastRequest": "Letzte Anfrage: {{date}}"
},
"save": "AI-Einstellungen speichern"
}
},
"liveShowSettings": {
"title": "Live-Show-Einstellungen",
@@ -3001,6 +3056,7 @@
},
"features": {
"advanced_analytics": "Erweiterte Statistiken",
"ai_styling": "AI-Styling",
"basic_uploads": "Basis-Uploads",
"custom_branding": "Eigenes Branding",
"custom_tasks": "Benutzerdefinierte Fotoaufgaben",

View File

@@ -85,15 +85,28 @@
"lemonsqueezy_voided": "The PayPal payment was voided."
},
"sections": {
"currentEvent": {
"title": "Current event",
"hint": "This section shows what is active for your currently selected event.",
"empty": "Select an event to view event-specific packages and add-ons.",
"eventLabel": "Selected event",
"packageActive": "Active for this event",
"packageExpires": "Gallery active until {{date}}",
"noPackage": "No package is currently assigned to this event.",
"addonsLabel": "Add-ons for this event",
"noAddons": "No add-ons purchased for this event.",
"eventAddonSource": "Source: event package"
},
"invoices": {
"title": "Invoices & payments",
"hint": "Review transactions and download receipts.",
"empty": "No payments found."
},
"addOns": {
"title": "Add-ons",
"hint": "Track extra photo, guest, or time bundles per event.",
"empty": "No add-ons booked."
"title": "Add-on purchase history",
"hint": "History of all add-on purchases across all events. An entry here is not automatically active for the currently selected event.",
"empty": "No add-ons booked.",
"otherEventNotice": "Purchased for another event"
},
"overview": {
"title": "Package overview",
@@ -125,12 +138,13 @@
}
},
"packages": {
"title": "Packages",
"hint": "Active package, limits, and history at a glance.",
"title": "Package history (all events)",
"hint": "All purchased packages across all events.",
"description": "Overview of active and past packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
"statusActiveTenant": "Currently active",
"statusInactive": "Inactive",
"used": "Events used",
"available": "Remaining",
@@ -2394,6 +2408,7 @@
"photo_likes_enabled": "Photo likes",
"event_checklist": "Event checklist",
"advanced_analytics": "Advanced analytics",
"ai_styling": "AI styling",
"branding_allowed": "Branding",
"watermark_allowed": "Watermarks",
"watermark_base": "Fotospiel watermark",
@@ -2588,7 +2603,47 @@
"saveFailed": "Automation settings could not be saved."
},
"emptyModeration": "No uploads match this filter.",
"emptyLive": "No photos waiting for Live Show."
"emptyLive": "No photos waiting for Live Show.",
"aiAddon": {
"title": "AI Magic Edits",
"body": "AI styling is hidden until this event is entitled. Unlock it via add-on or include it with Premium.",
"buyAction": "Buy AI styling add-on",
"upgradeAction": "View Premium package"
},
"aiSettings": {
"title": "AI Styling Controls",
"subtitle": "Enable AI edits per event, configure allowed presets, and monitor usage/failures.",
"loadFailed": "AI settings could not be loaded.",
"saveFailed": "AI settings could not be saved.",
"saved": "AI settings saved.",
"enabled": {
"label": "Enable AI edits for this event",
"hint": "When disabled, guest and admin AI requests are blocked for this event."
},
"customPrompt": {
"label": "Allow custom prompts",
"hint": "If disabled, users must choose one of the allowed presets."
},
"policyMessage": {
"label": "Policy message",
"hint": "Shown to users when AI edits are disabled or a style is blocked.",
"placeholder": "Optional message for guests/admins"
},
"styles": {
"label": "Allowed AI styles",
"hint": "No selection means all active styles are allowed.",
"empty": "No active AI styles found.",
"clear": "Allow all styles"
},
"usage": {
"title": "Usage overview",
"total": "Total",
"succeeded": "Succeeded",
"failed": "Failed",
"lastRequest": "Last request: {{date}}"
},
"save": "Save AI settings"
}
},
"liveShowSettings": {
"title": "Live Show settings",
@@ -3003,6 +3058,7 @@
},
"features": {
"advanced_analytics": "Advanced Analytics",
"ai_styling": "AI Styling",
"basic_uploads": "Basic uploads",
"custom_branding": "Custom Branding",
"custom_tasks": "Custom photo tasks",

View File

@@ -13,8 +13,12 @@ import {
getTenantBillingTransactions,
getTenantPackagesOverview,
getTenantPackageCheckoutStatus,
getEvent,
TenantPackageSummary,
TenantEvent,
TenantBillingTransactionSummary,
EventAddonSummary,
PaginationMeta,
downloadTenantBillingReceipt,
} from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
@@ -43,18 +47,45 @@ import {
storePendingCheckout,
} from './lib/billingCheckout';
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
import { useEventContext } from '../context/EventContext';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
const BILLING_HISTORY_PAGE_SIZE = 8;
const CURRENT_EVENT_ADDONS_PAGE_SIZE = 6;
function createInitialPaginationMeta(perPage: number): PaginationMeta {
return {
current_page: 1,
last_page: 1,
per_page: perPage,
total: 0,
};
}
export default function MobileBillingPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const { activeEvent } = useEventContext();
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<TenantBillingTransactionSummary[]>([]);
const [transactionsMeta, setTransactionsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [addonsMeta, setAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
const [addonsLoadingMore, setAddonsLoadingMore] = React.useState(false);
const [scopeAddons, setScopeAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [scopeAddonsMeta, setScopeAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE)
);
const [scopeAddonsLoadingMore, setScopeAddonsLoadingMore] = React.useState(false);
const [scopeEvent, setScopeEvent] = React.useState<TenantEvent | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [showPackageHistory, setShowPackageHistory] = React.useState(false);
@@ -63,6 +94,7 @@ export default function MobileBillingPage() {
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const mismatchTrackingRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
@@ -79,13 +111,51 @@ export default function MobileBillingPage() {
try {
const [pkg, trx, addonHistory] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
getTenantBillingTransactions(1, BILLING_HISTORY_PAGE_SIZE).catch(() => ({
data: [] as TenantBillingTransactionSummary[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
getTenantAddonHistory({ page: 1, perPage: BILLING_HISTORY_PAGE_SIZE }).catch(() => ({
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
]);
let scopedEvent: TenantEvent | null = null;
let scopedAddons: TenantAddonHistoryEntry[] = [];
const scopeSlug = activeEvent?.slug ?? null;
const scopeEventIdFallback = activeEvent?.id ?? null;
if (scopeSlug) {
scopedEvent = await getEvent(scopeSlug).catch(() => activeEvent ?? null);
const scopedEventId = scopedEvent?.id ?? scopeEventIdFallback;
if (scopedEventId) {
const scopedAddonHistory = await getTenantAddonHistory({
eventId: scopedEventId,
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
page: 1,
}).catch(() => ({
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE),
}));
scopedAddons = scopedAddonHistory.data ?? [];
setScopeAddonsMeta(scopedAddonHistory.meta ?? createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
} else {
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
}
} else {
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
}
setPackages(pkg.packages ?? []);
setActivePackage(pkg.activePackage ?? null);
setTransactions(trx.data ?? []);
setTransactionsMeta(trx.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setAddons(addonHistory.data ?? []);
setAddonsMeta(addonHistory.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setScopeEvent(scopedEvent);
setScopeAddons(scopedAddons);
setError(null);
} catch (err) {
const message = getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.'));
@@ -93,7 +163,7 @@ export default function MobileBillingPage() {
} finally {
setLoading(false);
}
}, [t]);
}, [activeEvent, t]);
const scrollToPackages = React.useCallback(() => {
packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -116,6 +186,49 @@ export default function MobileBillingPage() {
);
const hiddenPackageCount = Math.max(0, nonActivePackages.length - 3);
const visiblePackageHistory = showPackageHistory ? nonActivePackages : nonActivePackages.slice(0, 3);
const scopedEventName = React.useMemo(() => {
if (!scopeEvent) {
return null;
}
return resolveLinkedEventName(scopeEvent.name, t);
}, [scopeEvent, t]);
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
const scopedEventPackage = scopeEvent?.package ?? null;
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
const rows = scopeEvent?.addons;
return Array.isArray(rows) ? rows : [];
}, [scopeEvent?.addons]);
const scopeMismatchCount = React.useMemo(() => {
if (!activeEventId) {
return 0;
}
return addons.filter((addon) => addon.event?.id && Number(addon.event.id) !== Number(activeEventId)).length;
}, [activeEventId, addons]);
const trackBillingInteraction = React.useCallback(
(action: string, value?: number) => {
if (typeof window === 'undefined') {
return;
}
const maybePaq = (window as unknown as { _paq?: unknown[] })._paq;
if (!Array.isArray(maybePaq)) {
return;
}
const label = activeEventId ? `event:${activeEventId}` : 'event:none';
const payload: (string | number)[] = ['trackEvent', 'Admin Billing', action, label];
if (typeof value === 'number' && Number.isFinite(value)) {
payload.push(value);
}
maybePaq.push(payload);
},
[activeEventId],
);
const handleReceiptDownload = React.useCallback(
async (transaction: TenantBillingTransactionSummary) => {
@@ -138,10 +251,100 @@ export default function MobileBillingPage() {
[t],
);
const loadMoreTransactions = React.useCallback(async () => {
if (transactionsLoadingMore) {
return;
}
if (transactionsMeta.current_page >= transactionsMeta.last_page) {
return;
}
setTransactionsLoadingMore(true);
try {
const nextPage = transactionsMeta.current_page + 1;
const result = await getTenantBillingTransactions(nextPage, BILLING_HISTORY_PAGE_SIZE);
setTransactions((current) => [...current, ...(result.data ?? [])]);
setTransactionsMeta(result.meta ?? transactionsMeta);
trackBillingInteraction('transactions_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setTransactionsLoadingMore(false);
}
}, [trackBillingInteraction, transactionsLoadingMore, transactionsMeta, t]);
const loadMoreAddonHistory = React.useCallback(async () => {
if (addonsLoadingMore) {
return;
}
if (addonsMeta.current_page >= addonsMeta.last_page) {
return;
}
setAddonsLoadingMore(true);
try {
const nextPage = addonsMeta.current_page + 1;
const result = await getTenantAddonHistory({
page: nextPage,
perPage: BILLING_HISTORY_PAGE_SIZE,
});
setAddons((current) => [...current, ...(result.data ?? [])]);
setAddonsMeta(result.meta ?? addonsMeta);
trackBillingInteraction('addon_history_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setAddonsLoadingMore(false);
}
}, [addonsLoadingMore, addonsMeta, t, trackBillingInteraction]);
const loadMoreScopeAddons = React.useCallback(async () => {
if (scopeAddonsLoadingMore) {
return;
}
if (!activeEventId || scopeAddonsMeta.current_page >= scopeAddonsMeta.last_page) {
return;
}
setScopeAddonsLoadingMore(true);
try {
const nextPage = scopeAddonsMeta.current_page + 1;
const result = await getTenantAddonHistory({
eventId: activeEventId,
page: nextPage,
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
});
setScopeAddons((current) => [...current, ...(result.data ?? [])]);
setScopeAddonsMeta(result.meta ?? scopeAddonsMeta);
trackBillingInteraction('scope_addons_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setScopeAddonsLoadingMore(false);
}
}, [activeEventId, scopeAddonsLoadingMore, scopeAddonsMeta, t, trackBillingInteraction]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (!activeEventId || scopeMismatchCount <= 0) {
return;
}
const key = `${activeEventId}:${scopeMismatchCount}`;
if (mismatchTrackingRef.current === key) {
return;
}
mismatchTrackingRef.current = key;
trackBillingInteraction('scope_mismatch_visible', scopeMismatchCount);
}, [activeEventId, scopeMismatchCount, trackBillingInteraction]);
React.useEffect(() => {
if (!location.hash) return;
const hash = location.hash.replace('#', '');
@@ -387,15 +590,122 @@ export default function MobileBillingPage() {
</MobileCard>
) : null}
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.currentEvent.title', 'Current event')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t(
'billing.sections.currentEvent.hint',
'This section shows what is active for your currently selected event.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('common.loading', 'Lädt...')}
</Text>
) : !scopeEvent ? (
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.empty', 'Select an event to view event-specific packages and add-ons.')}
</Text>
) : (
<YStack gap="$2">
<YStack gap="$1">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.eventLabel', 'Selected event')}
</Text>
{scopedEventName ? (
scopedEventPath ? (
<Pressable onPress={() => navigate(scopedEventPath)}>
<Text fontSize="$sm" color={primary} fontWeight="700">
{scopedEventName}
</Text>
</Pressable>
) : (
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{scopedEventName}
</Text>
)
) : null}
</YStack>
{scopedEventPackage ? (
<MobileCard borderColor={border} padding="$3" gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{scopedEventPackage.name ?? t('mobileBilling.packageFallback', 'Package')}
</Text>
<PillBadge tone="success">
{t('billing.sections.currentEvent.packageActive', 'Active for this event')}
</PillBadge>
</XStack>
{scopedEventPackage.expires_at ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.packageExpires', 'Gallery active until {{date}}', {
date: formatDate(scopedEventPackage.expires_at),
})}
</Text>
) : null}
</MobileCard>
) : (
<MobileCard borderColor={border} padding="$3" gap="$1">
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.noPackage', 'No package is currently assigned to this event.')}
</Text>
</MobileCard>
)}
<YStack gap="$1.5">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.addonsLabel', 'Add-ons for this event')}
</Text>
{scopeAddons.length > 0 ? (
<YStack gap="$1.5">
{scopeAddons.map((addon) => (
<AddonRow key={`scope-${addon.id}`} addon={addon} hideEventLink />
))}
{scopeAddonsMeta.current_page < scopeAddonsMeta.last_page ? (
<CTAButton
label={
scopeAddonsLoadingMore
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
}
onPress={() => void loadMoreScopeAddons()}
tone="ghost"
/>
) : null}
</YStack>
) : scopedEventAddons.length > 0 ? (
<YStack gap="$1.5">
{scopedEventAddons.slice(0, 6).map((addon) => (
<EventAddonRow key={`event-addon-${addon.id}`} addon={addon} />
))}
</YStack>
) : (
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
</Text>
)}
</YStack>
</YStack>
)}
</MobileCard>
<MobileCard gap="$2" ref={packagesRef as any}>
<XStack alignItems="center" gap="$2">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.packages.title', 'Packages')}
{t('billing.sections.packages.title', 'Package history (all events)')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
{t(
'billing.sections.packages.hint',
'All purchased packages across all events.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
@@ -406,7 +716,7 @@ export default function MobileBillingPage() {
{activePackage ? (
<PackageCard
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
label={t('billing.sections.packages.card.statusActiveTenant', 'Currently active')}
isActive
onOpenShop={() => navigate(shopLink)}
/>
@@ -470,7 +780,7 @@ export default function MobileBillingPage() {
</YStack>
) : (
<YStack gap="$1.5">
{transactions.slice(0, 8).map((trx) => {
{transactions.map((trx) => {
const statusLabel = trx.status
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
: '—';
@@ -519,6 +829,17 @@ export default function MobileBillingPage() {
</XStack>
);
})}
{transactionsMeta.current_page < transactionsMeta.last_page ? (
<CTAButton
label={
transactionsLoadingMore
? t('billing.sections.transactions.loadingMore', 'Laden…')
: t('billing.sections.transactions.loadMore', 'Weitere Transaktionen laden')
}
onPress={() => void loadMoreTransactions()}
tone="ghost"
/>
) : null}
</YStack>
)}
</MobileCard>
@@ -527,11 +848,14 @@ export default function MobileBillingPage() {
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.addOns.title', 'Add-ons')}
{t('billing.sections.addOns.title', 'Add-on purchase history')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
{t(
'billing.sections.addOns.hint',
'History across all events. Entries here are historical and not automatically active for the current event.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
@@ -543,9 +867,20 @@ export default function MobileBillingPage() {
</Text>
) : (
<YStack gap="$1.5">
{addons.slice(0, 8).map((addon) => (
<AddonRow key={addon.id} addon={addon} />
{addons.map((addon) => (
<AddonRow key={addon.id} addon={addon} currentEventId={activeEventId} />
))}
{addonsMeta.current_page < addonsMeta.last_page ? (
<CTAButton
label={
addonsLoadingMore
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
}
onPress={() => void loadMoreAddonHistory()}
tone="ghost"
/>
) : null}
</YStack>
)}
</MobileCard>
@@ -846,7 +1181,15 @@ function formatAmount(value: number | null | undefined, currency: string | null
}
}
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
function AddonRow({
addon,
hideEventLink = false,
currentEventId = null,
}: {
addon: TenantAddonHistoryEntry;
hideEventLink?: boolean;
currentEventId?: number | null;
}) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
@@ -861,6 +1204,12 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
null;
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
const purchasedForDifferentEvent = Boolean(
!hideEventLink &&
currentEventId &&
addon.event?.id &&
Number(addon.event.id) !== Number(currentEventId)
);
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
const impactBadges = hasImpact ? (
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
@@ -884,7 +1233,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
{eventName ? (
{!hideEventLink && eventName ? (
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
@@ -909,9 +1258,56 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
<Text fontSize="$xs" color={muted}>
{formatDate(addon.purchased_at)}
</Text>
{purchasedForDifferentEvent ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.addOns.otherEventNotice', 'Purchased for another event')}
</Text>
) : null}
</MobileCard>
);
}
function EventAddonRow({ addon }: { addon: EventAddonSummary }) {
const { t } = useTranslation('management');
const { border, textStrong, text, muted } = useAdminTheme();
const labels: Record<EventAddonSummary['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
};
const status = labels[addon.status];
return (
<MobileCard borderColor={border} padding="$3" gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{addon.label ?? addon.key}
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
<Text fontSize="$sm" color={text}>
{formatDate(addon.purchased_at)}
</Text>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.eventAddonSource', 'Source: event package')}
</Text>
</MobileCard>
);
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);

View File

@@ -11,9 +11,11 @@ import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, ContentTabs } from './components/Primitives';
import { MobileField, MobileSelect } from './components/FormControls';
import { MobileField, MobileSelect, MobileTextArea } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
AiEditStyle,
AiEditUsageSummary,
approveAndLiveShowPhoto,
approveLiveShowPhoto,
clearLiveShowPhoto,
@@ -21,9 +23,12 @@ import {
ControlRoomUploaderRule,
createEventAddonCheckout,
EventAddonCatalogItem,
EventAiEditingSettings,
EventLimitSummary,
getAddonCatalog,
featurePhoto,
getEventAiEditSummary,
getEventAiStyles,
getEventPhotos,
getEvents,
getLiveShowQueue,
@@ -75,6 +80,20 @@ const LIVE_STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string;
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
];
type AiSettingsDraft = {
enabled: boolean;
allow_custom_prompt: boolean;
allowed_style_keys: string[];
policy_message: string;
};
const DEFAULT_AI_SETTINGS: AiSettingsDraft = {
enabled: true,
allow_custom_prompt: true,
allowed_style_keys: [],
policy_message: '',
};
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
@@ -280,6 +299,53 @@ function formatDeviceId(deviceId: string): string {
return `${deviceId.slice(0, 4)}...${deviceId.slice(-4)}`;
}
function normalizeAiSettingsDraft(
source: EventAiEditingSettings | null | undefined,
fallbackAllowedStyleKeys: string[] = [],
): AiSettingsDraft {
return {
enabled: source?.enabled === undefined ? true : Boolean(source.enabled),
allow_custom_prompt:
source?.allow_custom_prompt === undefined ? true : Boolean(source.allow_custom_prompt),
allowed_style_keys:
Array.isArray(source?.allowed_style_keys) && source?.allowed_style_keys.length
? source.allowed_style_keys.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
: fallbackAllowedStyleKeys,
policy_message: typeof source?.policy_message === 'string' ? source.policy_message : '',
};
}
function normalizeAiStyleKeyList(keys: string[]): string[] {
return Array.from(
new Set(
keys
.filter((value) => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
).sort((left, right) => left.localeCompare(right));
}
function isAiSettingsDirty(current: AiSettingsDraft, initial: AiSettingsDraft): boolean {
if (current.enabled !== initial.enabled) {
return true;
}
if (current.allow_custom_prompt !== initial.allow_custom_prompt) {
return true;
}
if ((current.policy_message ?? '').trim() !== (initial.policy_message ?? '').trim()) {
return true;
}
const currentKeys = normalizeAiStyleKeyList(current.allowed_style_keys);
const initialKeys = normalizeAiStyleKeyList(initial.allowed_style_keys);
if (currentKeys.length !== initialKeys.length) {
return true;
}
return currentKeys.some((key, index) => key !== initialKeys[index]);
}
export default function MobileEventControlRoomPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -312,7 +378,7 @@ export default function MobileEventControlRoomPage() {
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests' | 'ai'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false);
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
@@ -347,6 +413,31 @@ export default function MobileEventControlRoomPage() {
const autoRemoveLiveOnHide = Boolean(controlRoomSettings.auto_remove_live_on_hide);
const trustedUploaders = controlRoomSettings.trusted_uploaders ?? [];
const forceReviewUploaders = controlRoomSettings.force_review_uploaders ?? [];
const aiCapabilities = (activeEvent?.capabilities ?? null) as
| {
ai_styling?: boolean;
ai_styling_addon_keys?: string[] | null;
}
| null;
const aiStylingEntitled = Boolean(aiCapabilities?.ai_styling);
const aiStylingAddonKeys =
Array.isArray(aiCapabilities?.ai_styling_addon_keys) && aiCapabilities?.ai_styling_addon_keys.length
? aiCapabilities.ai_styling_addon_keys
: ['ai_styling_unlock'];
const [aiStyles, setAiStyles] = React.useState<AiEditStyle[]>([]);
const [aiUsageSummary, setAiUsageSummary] = React.useState<AiEditUsageSummary | null>(null);
const [aiSettingsDraft, setAiSettingsDraft] = React.useState<AiSettingsDraft>(DEFAULT_AI_SETTINGS);
const [initialAiSettingsDraft, setInitialAiSettingsDraft] = React.useState<AiSettingsDraft>(DEFAULT_AI_SETTINGS);
const [aiSettingsLoading, setAiSettingsLoading] = React.useState(false);
const [aiSettingsSaving, setAiSettingsSaving] = React.useState(false);
const [aiSettingsError, setAiSettingsError] = React.useState<string | null>(null);
const aiStylingAddon = React.useMemo(() => {
return catalogAddons.find((addon) => addon.price_id && aiStylingAddonKeys.includes(addon.key)) ?? null;
}, [aiStylingAddonKeys, catalogAddons]);
const aiSettingsDirty = React.useMemo(
() => isAiSettingsDirty(aiSettingsDraft, initialAiSettingsDraft),
[aiSettingsDraft, initialAiSettingsDraft],
);
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const [syncingQueue, setSyncingQueue] = React.useState(false);
@@ -388,6 +479,99 @@ export default function MobileEventControlRoomPage() {
[controlRoomSettings, refetch, slug, t],
);
const loadAiSettings = React.useCallback(async () => {
if (!slug || !aiStylingEntitled) {
setAiStyles([]);
setAiUsageSummary(null);
return;
}
setAiSettingsLoading(true);
setAiSettingsError(null);
try {
const [stylesResult, usageResult] = await Promise.all([
getEventAiStyles(slug),
getEventAiEditSummary(slug),
]);
setAiStyles(stylesResult.styles);
setAiUsageSummary(usageResult);
if (stylesResult.meta.allowed_style_keys && stylesResult.meta.allowed_style_keys.length) {
setAiSettingsDraft((previous) => ({
...previous,
allowed_style_keys: stylesResult.meta.allowed_style_keys ?? previous.allowed_style_keys,
}));
}
} catch (err) {
if (!isAuthError(err)) {
setAiSettingsError(
getApiErrorMessage(
err,
t('controlRoom.aiSettings.loadFailed', 'AI settings could not be loaded.')
),
);
}
} finally {
setAiSettingsLoading(false);
}
}, [aiStylingEntitled, slug, t]);
const saveAiSettings = React.useCallback(async () => {
if (!slug || !aiStylingEntitled || !aiSettingsDirty) {
return;
}
const payload: EventAiEditingSettings = {
enabled: aiSettingsDraft.enabled,
allow_custom_prompt: aiSettingsDraft.allow_custom_prompt,
allowed_style_keys: normalizeAiStyleKeyList(aiSettingsDraft.allowed_style_keys),
policy_message: aiSettingsDraft.policy_message.trim() || null,
};
setAiSettingsSaving(true);
setAiSettingsError(null);
try {
await updateEvent(slug, {
settings: {
ai_editing: payload,
},
});
setInitialAiSettingsDraft({
...aiSettingsDraft,
allowed_style_keys: payload.allowed_style_keys ?? [],
policy_message: payload.policy_message ?? '',
});
await loadAiSettings();
refetch();
toast.success(t('controlRoom.aiSettings.saved', 'AI settings saved.'));
} catch (err) {
setAiSettingsError(
getApiErrorMessage(
err,
t('controlRoom.aiSettings.saveFailed', 'AI settings could not be saved.')
),
);
} finally {
setAiSettingsSaving(false);
}
}, [aiSettingsDirty, aiSettingsDraft, aiStylingEntitled, loadAiSettings, refetch, slug, t]);
const toggleAiStyleKey = React.useCallback((styleKey: string) => {
setAiSettingsDraft((previous) => {
const hasKey = previous.allowed_style_keys.includes(styleKey);
const next = hasKey
? previous.allowed_style_keys.filter((key) => key !== styleKey)
: [...previous.allowed_style_keys, styleKey];
return {
...previous,
allowed_style_keys: normalizeAiStyleKeyList(next),
};
});
}, []);
const uploaderOptions = React.useMemo(() => {
const options = new Map<string, { deviceId: string; label: string }>();
const addPhoto = (photo: TenantPhoto) => {
@@ -503,6 +687,21 @@ export default function MobileEventControlRoomPage() {
setForceReviewSelection('');
}, [activeEvent?.settings?.control_room, activeEvent?.slug]);
React.useEffect(() => {
const eventSettings = (activeEvent?.settings?.ai_editing ?? null) as EventAiEditingSettings | null;
const capabilityStyleKeys = Array.isArray(aiCapabilities?.ai_styling_allowed_style_keys)
? aiCapabilities.ai_styling_allowed_style_keys
: [];
const next = normalizeAiSettingsDraft(eventSettings, capabilityStyleKeys);
setAiSettingsDraft(next);
setInitialAiSettingsDraft(next);
setAiSettingsError(null);
}, [
activeEvent?.settings?.ai_editing,
activeEvent?.slug,
aiCapabilities?.ai_styling_allowed_style_keys,
]);
const ensureSlug = React.useCallback(async () => {
if (slug) {
return slug;
@@ -533,6 +732,16 @@ export default function MobileEventControlRoomPage() {
setLivePage(1);
}, [liveStatusFilter, slug]);
React.useEffect(() => {
if (!slug || !aiStylingEntitled) {
setAiStyles([]);
setAiUsageSummary(null);
return;
}
loadAiSettings();
}, [aiStylingEntitled, loadAiSettings, slug]);
React.useEffect(() => {
if (activeTab === 'moderation') {
moderationResetRef.current = true;
@@ -906,10 +1115,12 @@ export default function MobileEventControlRoomPage() {
],
);
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? scopeOrKey
: scopeOrKey === 'ai' || scopeOrKey.includes('ai')
? 'ai'
: scopeOrKey.includes('gallery')
? 'gallery'
: scopeOrKey.includes('guest')
@@ -918,16 +1129,18 @@ export default function MobileEventControlRoomPage() {
const addonKey =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? selectAddonKeyForScope(catalogAddons, scope)
? selectAddonKeyForScope(catalogAddons, scope as 'photos' | 'gallery' | 'guests')
: scopeOrKey === 'ai'
? aiStylingAddon?.key ?? 'ai_styling_unlock'
: scopeOrKey;
return { scope, addonKey };
}
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
if (!slug) return;
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests', addonKey });
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests' | 'ai', addonKey });
setConsentOpen(true);
}
@@ -1433,6 +1646,195 @@ export default function MobileEventControlRoomPage() {
</YStack>
</MobileCard>
{!moderationLoading ? (
aiStylingEntitled ? (
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('controlRoom.aiSettings.title', 'AI Styling Controls')}
</Text>
</XStack>
<CTAButton
tone="ghost"
fullWidth={false}
disabled={aiSettingsLoading || aiSettingsSaving}
label={t('common.refresh', 'Refresh')}
onPress={() => loadAiSettings()}
/>
</XStack>
<Text fontSize="$sm" color={muted}>
{t(
'controlRoom.aiSettings.subtitle',
'Enable AI edits per event, configure allowed presets, and monitor usage/failures.'
)}
</Text>
{aiSettingsError ? (
<Text fontSize="$sm" fontWeight="700" color={danger}>
{aiSettingsError}
</Text>
) : null}
<MobileField
label={t('controlRoom.aiSettings.enabled.label', 'Enable AI edits for this event')}
hint={t(
'controlRoom.aiSettings.enabled.hint',
'When disabled, guest and admin AI requests are blocked for this event.'
)}
>
<Switch
size="$3"
checked={aiSettingsDraft.enabled}
disabled={aiSettingsSaving}
onCheckedChange={(checked: boolean) =>
setAiSettingsDraft((previous) => ({ ...previous, enabled: Boolean(checked) }))
}
aria-label={t('controlRoom.aiSettings.enabled.label', 'Enable AI edits for this event')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.aiSettings.customPrompt.label', 'Allow custom prompts')}
hint={t(
'controlRoom.aiSettings.customPrompt.hint',
'If disabled, users must choose one of the allowed presets.'
)}
>
<Switch
size="$3"
checked={aiSettingsDraft.allow_custom_prompt}
disabled={aiSettingsSaving}
onCheckedChange={(checked: boolean) =>
setAiSettingsDraft((previous) => ({ ...previous, allow_custom_prompt: Boolean(checked) }))
}
aria-label={t('controlRoom.aiSettings.customPrompt.label', 'Allow custom prompts')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.aiSettings.policyMessage.label', 'Policy message')}
hint={t(
'controlRoom.aiSettings.policyMessage.hint',
'Shown to users when AI edits are disabled or a style is blocked.'
)}
>
<MobileTextArea
value={aiSettingsDraft.policy_message}
maxLength={280}
placeholder={t('controlRoom.aiSettings.policyMessage.placeholder', 'Optional message for guests/admins')}
onChange={(event) =>
setAiSettingsDraft((previous) => ({ ...previous, policy_message: event.target.value }))
}
/>
</MobileField>
<MobileField
label={t('controlRoom.aiSettings.styles.label', 'Allowed AI styles')}
hint={t(
'controlRoom.aiSettings.styles.hint',
'No selection means all active styles are allowed.'
)}
>
{aiSettingsLoading ? (
<SkeletonCard height={78} />
) : aiStyles.length === 0 ? (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.aiSettings.styles.empty', 'No active AI styles found.')}
</Text>
) : (
<YStack gap="$2">
<XStack flexWrap="wrap" gap="$2">
{aiStyles.map((style) => {
const selected = aiSettingsDraft.allowed_style_keys.includes(style.key);
return (
<Pressable
key={style.id}
onPress={() => toggleAiStyleKey(style.key)}
disabled={aiSettingsSaving}
aria-label={style.name}
style={{
padding: '7px 12px',
borderRadius: 999,
borderWidth: 1,
borderStyle: 'solid',
borderColor: selected ? activePillBorder : border,
backgroundColor: selected ? activePillBg : 'transparent',
opacity: aiSettingsSaving ? 0.6 : 1,
}}
>
<Text fontSize="$xs" fontWeight={selected ? '700' : '600'} color={selected ? text : muted}>
{style.name}
</Text>
</Pressable>
);
})}
</XStack>
<CTAButton
tone="ghost"
fullWidth={false}
disabled={aiSettingsSaving || aiSettingsDraft.allowed_style_keys.length === 0}
label={t('controlRoom.aiSettings.styles.clear', 'Allow all styles')}
onPress={() =>
setAiSettingsDraft((previous) => ({ ...previous, allowed_style_keys: [] }))
}
/>
</YStack>
)}
</MobileField>
{aiUsageSummary ? (
<MobileCard borderColor={border} backgroundColor={surfaceMuted}>
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.aiSettings.usage.title', 'Usage overview')}
</Text>
<XStack gap="$3" flexWrap="wrap">
<YStack minWidth={88}>
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.total', 'Total')}</Text>
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{aiUsageSummary.total}</Text>
</YStack>
<YStack minWidth={88}>
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.succeeded', 'Succeeded')}</Text>
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{aiUsageSummary.status_counts.succeeded ?? 0}
</Text>
</YStack>
<YStack minWidth={88}>
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.failed', 'Failed')}</Text>
<Text fontSize="$lg" fontWeight="800" color={danger}>{aiUsageSummary.failed_total}</Text>
</YStack>
</XStack>
{aiUsageSummary.last_requested_at ? (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.aiSettings.usage.lastRequest', 'Last request: {{date}}', {
date: new Date(aiUsageSummary.last_requested_at).toLocaleString(),
})}
</Text>
) : null}
</YStack>
</MobileCard>
) : null}
<XStack gap="$2" flexWrap="wrap">
<CTAButton
tone="primary"
fullWidth={false}
disabled={!aiSettingsDirty || aiSettingsSaving}
loading={aiSettingsSaving}
label={t('controlRoom.aiSettings.save', 'Save AI settings')}
onPress={() => saveAiSettings()}
/>
</XStack>
</MobileCard>
) : null
) : null}
{!moderationLoading ? (
<LimitWarnings
limits={limits}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
const navigateMock = vi.fn();
@@ -20,6 +20,9 @@ const tMock = (
const downloadReceiptMock = vi.fn().mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }));
const triggerDownloadMock = vi.fn();
const eventContext = {
activeEvent: null as any,
};
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
@@ -119,6 +122,10 @@ vi.mock('../../constants', () => ({
adminPath: (path: string) => path,
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => eventContext,
}));
vi.mock('../billingUsage', () => ({
buildPackageUsageMetrics: () => [],
formatPackageEventAllowance: () => '—',
@@ -145,6 +152,7 @@ vi.mock('../invite-layout/export-utils', () => ({
}));
vi.mock('../../api', () => ({
getEvent: vi.fn(),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
getTenantBillingTransactions: vi.fn().mockResolvedValue({
data: [
@@ -170,7 +178,150 @@ import MobileBillingPage from '../BillingPage';
import * as api from '../../api';
describe('MobileBillingPage', () => {
beforeEach(() => {
eventContext.activeEvent = null;
vi.clearAllMocks();
vi.mocked(api.getTenantPackagesOverview).mockResolvedValue({ packages: [], activePackage: null });
vi.mocked(api.getTenantBillingTransactions).mockResolvedValue({
data: [
{
id: 1,
status: 'completed',
amount: 49,
currency: 'EUR',
provider: 'paypal',
provider_id: 'ORDER-1',
package_name: 'Starter',
purchased_at: '2024-01-01T00:00:00Z',
receipt_url: '/api/v1/billing/transactions/1/receipt',
},
],
} as any);
vi.mocked(api.getTenantAddonHistory).mockResolvedValue({ data: [] } as any);
vi.mocked(api.getEvent).mockResolvedValue(null as any);
});
it('shows current event scoped entitlements separately from tenant history', async () => {
eventContext.activeEvent = {
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
};
vi.mocked(api.getEvent).mockResolvedValueOnce({
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
event_date: null,
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
package: {
id: 201,
name: 'Event Paket',
price: 19,
purchased_at: '2024-01-01T00:00:00Z',
expires_at: '2024-02-01T00:00:00Z',
branding_allowed: true,
watermark_allowed: true,
features: [],
},
capabilities: {
ai_styling: false,
ai_styling_granted_by: null,
ai_styling_required_feature: null,
ai_styling_addon_keys: [],
ai_styling_event_enabled: true,
ai_styling_allow_custom_prompt: true,
ai_styling_allowed_style_keys: [],
ai_styling_policy_message: null,
},
addons: [],
limits: null,
member_permissions: [],
} as any);
vi.mocked(api.getTenantAddonHistory)
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({
data: [
{
id: 302,
addon_key: 'extra_photos_100',
label: '+100 photos',
event: { id: 99, slug: 'fruehlingsfest', name: { de: 'Frühlingsfest' } },
amount: 9,
currency: 'EUR',
status: 'completed',
purchased_at: '2024-01-20T00:00:00Z',
extra_photos: 100,
extra_guests: 0,
extra_gallery_days: 0,
quantity: 1,
},
],
} as any);
render(<MobileBillingPage />);
await screen.findByText('Current event');
expect(screen.getByText('Frühlingsfest')).toBeInTheDocument();
expect(screen.getByText('Active for this event')).toBeInTheDocument();
expect(screen.getAllByText('+100 photos').length).toBeGreaterThan(0);
expect(api.getTenantAddonHistory).toHaveBeenCalledWith({ eventId: 99, perPage: 6, page: 1 });
});
it('marks add-ons purchased for another event in global history', async () => {
eventContext.activeEvent = {
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
};
vi.mocked(api.getEvent).mockResolvedValueOnce({
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
event_date: null,
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
package: null,
addons: [],
limits: null,
member_permissions: [],
} as any);
vi.mocked(api.getTenantAddonHistory)
.mockResolvedValueOnce({
data: [
{
id: 400,
addon_key: 'ai_styling_unlock',
label: 'AI Magic Edits',
event: { id: 77, slug: 'sommerfest', name: { de: 'Sommerfest' } },
amount: 12,
currency: 'EUR',
status: 'completed',
purchased_at: '2024-01-03T00:00:00Z',
extra_photos: 0,
extra_guests: 0,
extra_gallery_days: 0,
quantity: 1,
},
],
} as any)
.mockResolvedValueOnce({ data: [] } as any);
render(<MobileBillingPage />);
await screen.findByText('Purchased for another event');
});
it('shows linked event information for a package', async () => {
eventContext.activeEvent = null;
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
activePackage: {
id: 11,
@@ -207,6 +358,7 @@ describe('MobileBillingPage', () => {
});
it('shows only recent package history and can expand the rest', async () => {
eventContext.activeEvent = null;
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
activePackage: {
id: 1,
@@ -317,6 +469,7 @@ describe('MobileBillingPage', () => {
});
it('downloads receipts via the API helper', async () => {
eventContext.activeEvent = null;
render(<MobileBillingPage />);
const receiptLink = await screen.findByText('Beleg');

View File

@@ -24,6 +24,10 @@ const FEATURE_LABELS: Record<string, { key: string; fallback: string }> = {
key: 'mobileDashboard.packageSummary.feature.custom_branding',
fallback: 'Custom branding',
},
ai_styling: {
key: 'mobileDashboard.packageSummary.feature.ai_styling',
fallback: 'AI styling',
},
custom_tasks: {
key: 'mobileDashboard.packageSummary.feature.custom_tasks',
fallback: 'Custom tasks',

View File

@@ -0,0 +1,229 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
const fetchGuestAiStylesMock = vi.fn();
const createGuestAiEditMock = vi.fn();
const fetchGuestAiEditStatusMock = vi.fn();
const translate = (key: string, options?: unknown, fallback?: string) => {
if (typeof fallback === 'string') {
return fallback;
}
if (typeof options === 'string') {
return options;
}
return key;
};
vi.mock('../services/aiEditsApi', () => ({
fetchGuestAiStyles: (...args: unknown[]) => fetchGuestAiStylesMock(...args),
createGuestAiEdit: (...args: unknown[]) => createGuestAiEditMock(...args),
fetchGuestAiEditStatus: (...args: unknown[]) => fetchGuestAiEditStatusMock(...args),
}));
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: translate,
}),
}));
vi.mock('../lib/toast', () => ({
pushGuestToast: vi.fn(),
}));
vi.mock('../lib/guestTheme', () => ({
useGuestThemeVariant: () => ({ isDark: false }),
}));
vi.mock('lucide-react', () => ({
Copy: () => <span>copy</span>,
Download: () => <span>download</span>,
Loader2: () => <span>loader</span>,
MessageSquare: () => <span>message</span>,
RefreshCcw: () => <span>refresh</span>,
Share2: () => <span>share</span>,
Sparkles: () => <span>sparkles</span>,
Wand2: () => <span>wand</span>,
X: () => <span>x</span>,
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null);
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
return { Sheet };
});
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress} {...rest}>
{children}
</button>
),
}));
import AiMagicEditSheet from '../components/AiMagicEditSheet';
describe('AiMagicEditSheet', () => {
const originalOnLine = navigator.onLine;
beforeEach(() => {
fetchGuestAiStylesMock.mockReset();
createGuestAiEditMock.mockReset();
fetchGuestAiEditStatusMock.mockReset();
Object.defineProperty(window.navigator, 'onLine', {
configurable: true,
value: true,
});
});
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(window.navigator, 'onLine', {
configurable: true,
value: originalOnLine,
});
});
it('loads styles and creates an ai edit request', async () => {
fetchGuestAiStylesMock.mockResolvedValue({
data: [
{
id: 1,
key: 'ghibli-soft',
name: 'Ghibli Soft',
description: 'Soft shading style',
},
],
meta: {},
});
createGuestAiEditMock.mockResolvedValue({
duplicate: false,
data: {
id: 15,
event_id: 2,
photo_id: 7,
status: 'succeeded',
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
outputs: [{ id: 99, provider_url: 'https://example.com/ai.jpg', is_primary: true }],
},
});
render(
<AiMagicEditSheet
open
onOpenChange={vi.fn()}
eventToken="event-token"
photoId={7}
originalImageUrl="/storage/original.jpg"
/>
);
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
fireEvent.click(screen.getByText('Generate AI edit'));
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByText('AI result')).toBeInTheDocument());
expect(screen.getByText('Copy link')).toBeInTheDocument();
});
it('shows an error when style loading fails', async () => {
fetchGuestAiStylesMock.mockRejectedValue(new Error('Styles not reachable'));
render(
<AiMagicEditSheet
open
onOpenChange={vi.fn()}
eventToken="event-token"
photoId={7}
originalImageUrl="/storage/original.jpg"
/>
);
expect(await screen.findByText('Styles not reachable')).toBeInTheDocument();
});
it('pauses polling while offline and resumes after reconnect', async () => {
Object.defineProperty(window.navigator, 'onLine', {
configurable: true,
value: false,
});
fetchGuestAiStylesMock.mockResolvedValue({
data: [{ id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' }],
meta: {},
});
createGuestAiEditMock.mockResolvedValue({
duplicate: false,
data: {
id: 22,
event_id: 2,
photo_id: 7,
status: 'processing',
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
outputs: [],
},
});
fetchGuestAiEditStatusMock.mockResolvedValue({
data: {
id: 22,
event_id: 2,
photo_id: 7,
status: 'succeeded',
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
outputs: [{ id: 7, provider_url: 'https://example.com/generated.jpg', is_primary: true }],
},
});
render(
<AiMagicEditSheet
open
onOpenChange={vi.fn()}
eventToken="event-token"
photoId={7}
originalImageUrl="/storage/original.jpg"
/>
);
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
fireEvent.click(screen.getByText('Generate AI edit'));
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
expect(await screen.findByText('You are offline. Status updates resume automatically when connection is back.')).toBeInTheDocument();
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(7000);
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(0);
Object.defineProperty(window.navigator, 'onLine', {
configurable: true,
value: true,
});
await act(async () => {
window.dispatchEvent(new Event('online'));
});
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
await Promise.resolve();
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,9 +1,13 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
const setSearchParamsMock = vi.fn();
const pushGuestToastMock = vi.fn();
const mockEventData = {
token: 'demo',
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
};
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
@@ -11,7 +15,7 @@ vi.mock('react-router-dom', () => ({
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo', event: { name: 'Demo Event' } }),
useEventData: () => mockEventData,
}));
vi.mock('../hooks/usePollGalleryDelta', () => ({
@@ -73,6 +77,10 @@ vi.mock('../components/ShareSheet', () => ({
default: () => null,
}));
vi.mock('../components/AiMagicEditSheet', () => ({
default: () => null,
}));
vi.mock('../lib/toast', () => ({
pushGuestToast: (...args: unknown[]) => pushGuestToastMock(...args),
}));
@@ -115,6 +123,8 @@ describe('GalleryScreen', () => {
pushGuestToastMock.mockClear();
fetchGalleryMock.mockReset();
fetchPhotoMock.mockReset();
mockEventData.token = 'demo';
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
});
afterEach(() => {
@@ -160,4 +170,33 @@ describe('GalleryScreen', () => {
expect(setSearchParamsMock).not.toHaveBeenCalled();
expect(pushGuestToastMock).not.toHaveBeenCalled();
});
it('does not show ai magic edit action when ai styling is not entitled', async () => {
fetchGalleryMock.mockResolvedValue({
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
});
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
render(<GalleryScreen />);
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
);
});
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
fetchGalleryMock.mockResolvedValue({
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
});
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
render(<GalleryScreen />);
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
);
});
});

View File

@@ -2,6 +2,11 @@ import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const mockEventData = {
token: 'token',
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
};
vi.mock('react-router-dom', () => ({
useParams: () => ({ photoId: '123' }),
useNavigate: () => vi.fn(),
@@ -36,8 +41,12 @@ vi.mock('../components/ShareSheet', () => ({
default: () => <div>ShareSheet</div>,
}));
vi.mock('../components/AiMagicEditSheet', () => ({
default: () => <div>AiMagicEditSheet</div>,
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token', event: { name: 'Demo Event' } }),
useEventData: () => mockEventData,
}));
vi.mock('../services/photosApi', () => ({
@@ -66,9 +75,18 @@ import PhotoLightboxScreen from '../screens/PhotoLightboxScreen';
describe('PhotoLightboxScreen', () => {
it('renders lightbox layout', async () => {
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
render(<PhotoLightboxScreen />);
expect(await screen.findByText('Gallery')).toBeInTheDocument();
expect(await screen.findByText('Like')).toBeInTheDocument();
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
});
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
render(<PhotoLightboxScreen />);
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,642 @@
import React from 'react';
import { Sheet } from '@tamagui/sheet';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Copy, Download, Loader2, MessageSquare, RefreshCcw, Share2, Sparkles, Wand2, X } from 'lucide-react';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
import {
createGuestAiEdit,
fetchGuestAiEditStatus,
fetchGuestAiStyles,
type GuestAiEditRequest,
type GuestAiStyle,
} from '../services/aiEditsApi';
import type { ApiError } from '../services/apiClient';
import { pushGuestToast } from '../lib/toast';
type AiMagicEditSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
eventToken: string | null;
photoId: number | null;
originalImageUrl: string | null;
};
const POLLABLE_STATUSES = new Set(['queued', 'processing']);
const MAX_POLL_ATTEMPTS = 72;
const POLL_INTERVAL_MS = 2500;
function resolveErrorMessage(error: unknown, fallback: string): string {
const apiError = error as ApiError;
if (typeof apiError?.message === 'string' && apiError.message.trim() !== '') {
return apiError.message;
}
return fallback;
}
function buildIdempotencyKey(photoId: number): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `guest-ai-${photoId}-${crypto.randomUUID()}`;
}
return `guest-ai-${photoId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function resolveOutputUrl(request: GuestAiEditRequest | null): string | null {
if (!request || !Array.isArray(request.outputs)) {
return null;
}
const normalizeStorageUrl = (storagePath?: string | null): string | null => {
if (!storagePath || typeof storagePath !== 'string') {
return null;
}
if (/^https?:/i.test(storagePath)) {
return storagePath;
}
const cleanPath = storagePath.replace(/^\/+/g, '');
if (cleanPath.startsWith('storage/')) {
return `/${cleanPath}`;
}
return `/storage/${cleanPath}`;
};
const primary = request.outputs.find(
(output) =>
output.is_primary
&& (
(typeof output.provider_url === 'string' && output.provider_url)
|| (typeof output.storage_path === 'string' && output.storage_path)
)
);
if (primary?.provider_url) {
return primary.provider_url;
}
if (primary?.storage_path) {
return normalizeStorageUrl(primary.storage_path);
}
const first = request.outputs.find(
(output) =>
(typeof output.provider_url === 'string' && output.provider_url)
|| (typeof output.storage_path === 'string' && output.storage_path)
);
if (first?.provider_url) {
return first.provider_url;
}
return normalizeStorageUrl(first?.storage_path);
}
export default function AiMagicEditSheet({
open,
onOpenChange,
eventToken,
photoId,
originalImageUrl,
}: AiMagicEditSheetProps) {
const { t } = useTranslation();
const { isDark } = useGuestThemeVariant();
const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [styles, setStyles] = React.useState<GuestAiStyle[]>([]);
const [stylesLoading, setStylesLoading] = React.useState(false);
const [stylesError, setStylesError] = React.useState<string | null>(null);
const [selectedStyleKey, setSelectedStyleKey] = React.useState<string | null>(null);
const [request, setRequest] = React.useState<GuestAiEditRequest | null>(null);
const [requestError, setRequestError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
const [isOnline, setIsOnline] = React.useState<boolean>(() => {
if (typeof navigator === 'undefined') {
return true;
}
return navigator.onLine;
});
const pollAttemptsRef = React.useRef(0);
const selectedStyle = React.useMemo(() => {
if (!selectedStyleKey) {
return null;
}
return styles.find((style) => style.key === selectedStyleKey) ?? null;
}, [selectedStyleKey, styles]);
const outputUrl = React.useMemo(() => resolveOutputUrl(request), [request]);
const resetRequestState = React.useCallback(() => {
setRequest(null);
setRequestError(null);
setSubmitting(false);
pollAttemptsRef.current = 0;
}, []);
const loadStyles = React.useCallback(async () => {
if (!eventToken || !photoId) {
return;
}
setStylesLoading(true);
setStylesError(null);
try {
const payload = await fetchGuestAiStyles(eventToken);
const nextStyles = Array.isArray(payload.data) ? payload.data : [];
setStyles(nextStyles);
if (nextStyles.length > 0) {
setSelectedStyleKey(nextStyles[0]?.key ?? null);
} else {
setSelectedStyleKey(null);
setStylesError(t('galleryPage.lightbox.aiMagicEditNoStyles', 'No AI styles are currently available.'));
}
} catch (error) {
setStyles([]);
setSelectedStyleKey(null);
setStylesError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStylesFailed', 'AI styles could not be loaded.')));
} finally {
setStylesLoading(false);
}
}, [eventToken, photoId, t]);
React.useEffect(() => {
if (!open) {
resetRequestState();
return;
}
if (!eventToken || !photoId) {
setStyles([]);
setSelectedStyleKey(null);
setStylesError(t('galleryPage.lightbox.aiMagicEditUnavailable', 'AI Magic Edit is currently unavailable.'));
return;
}
resetRequestState();
void loadStyles();
}, [eventToken, loadStyles, open, photoId, resetRequestState, t]);
React.useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
React.useEffect(() => {
if (!open || !eventToken || !request || !POLLABLE_STATUSES.has(request.status) || !isOnline) {
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setRequestError(t('galleryPage.lightbox.aiMagicEditPollingTimeout', 'AI generation took too long. Please try again.'));
return;
}
pollAttemptsRef.current += 1;
try {
const payload = await fetchGuestAiEditStatus(eventToken, request.id);
if (cancelled) {
return;
}
setRequest(payload.data);
if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) {
setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.'));
}
} catch (error) {
if (cancelled) {
return;
}
if (typeof navigator !== 'undefined' && !navigator.onLine) {
setIsOnline(false);
return;
}
setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStatusFailed', 'AI status could not be refreshed.')));
}
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [eventToken, isOnline, open, request, t]);
const startAiEdit = React.useCallback(async () => {
if (!eventToken || !photoId || !selectedStyleKey) {
return;
}
setSubmitting(true);
setRequestError(null);
try {
const payload = await createGuestAiEdit(eventToken, photoId, {
style_key: selectedStyleKey,
idempotency_key: buildIdempotencyKey(photoId),
metadata: {
client: 'guest-v2',
entrypoint: 'lightbox',
},
});
pollAttemptsRef.current = 0;
setRequest(payload.data);
if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) {
setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.'));
}
} catch (error) {
setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStartFailed', 'AI edit could not be started.')));
} finally {
setSubmitting(false);
}
}, [eventToken, photoId, selectedStyleKey, t]);
const downloadGenerated = React.useCallback(() => {
if (!outputUrl || !request?.id) {
return;
}
const link = document.createElement('a');
link.href = outputUrl;
link.download = `ai-edit-${request.id}.jpg`;
link.rel = 'noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [outputUrl, request?.id]);
const copyGeneratedLink = React.useCallback(async () => {
if (!outputUrl) {
return;
}
try {
await navigator.clipboard?.writeText(outputUrl);
pushGuestToast({ text: t('share.copySuccess', 'Link copied!') });
} catch (error) {
console.error('Copy generated link failed', error);
pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' });
}
}, [outputUrl, t]);
const shareGeneratedNative = React.useCallback(() => {
if (!outputUrl) {
return;
}
const shareData: ShareData = {
title: t('galleryPage.lightbox.aiMagicEditShareTitle', 'AI Magic Edit'),
text: t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!'),
url: outputUrl,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
navigator.share(shareData).catch(() => undefined);
return;
}
void copyGeneratedLink();
}, [copyGeneratedLink, outputUrl, t]);
const shareGeneratedWhatsApp = React.useCallback(() => {
if (!outputUrl) {
return;
}
const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!');
const waUrl = `https://wa.me/?text=${encodeURIComponent(`${text} ${outputUrl}`)}`;
window.open(waUrl, '_blank', 'noopener');
}, [outputUrl, t]);
const shareGeneratedMessages = React.useCallback(() => {
if (!outputUrl) {
return;
}
const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!');
const smsUrl = `sms:?&body=${encodeURIComponent(`${text} ${outputUrl}`)}`;
window.open(smsUrl, '_blank', 'noopener');
}, [outputUrl, t]);
const isProcessing = Boolean(request && POLLABLE_STATUSES.has(request.status));
const isDone = request?.status === 'succeeded' && Boolean(outputUrl);
const content = (
<YStack gap="$3">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" gap="$2">
<Wand2 size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<YStack>
<Text fontSize="$4" fontWeight="$8">
{t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7}>
{t('galleryPage.lightbox.aiMagicEditSubtitle', 'Choose a style and generate an AI version.')}
</Text>
</YStack>
</XStack>
<Button
size="$3"
circular
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={() => onOpenChange(false)}
aria-label={t('common.actions.close', 'Close')}
>
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
{stylesLoading ? (
<XStack alignItems="center" gap="$2" paddingVertical="$2">
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" color="$color" opacity={0.8}>
{t('galleryPage.lightbox.aiMagicEditLoadingStyles', 'Loading styles...')}
</Text>
</XStack>
) : null}
{stylesError ? (
<YStack gap="$2" padding="$3" borderRadius="$card" backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
<Text fontSize="$2" color="#FCA5A5">{stylesError}</Text>
<XStack gap="$2">
<Button onPress={() => void loadStyles()} backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
<XStack alignItems="center" gap="$1.5">
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">{t('common.actions.retry', 'Retry')}</Text>
</XStack>
</Button>
</XStack>
</YStack>
) : null}
{!stylesLoading && !stylesError && !request ? (
<YStack gap="$3">
<YStack gap="$2">
<Text fontSize="$2" fontWeight="$7">
{t('galleryPage.lightbox.aiMagicEditSelectStyle', 'Select style')}
</Text>
<XStack gap="$2" flexWrap="wrap">
{styles.map((style) => {
const selected = style.key === selectedStyleKey;
return (
<Button
key={style.key}
size="$3"
backgroundColor={selected ? '$primary' : mutedSurface}
borderWidth={1}
borderColor={selected ? '$primary' : mutedBorder}
onPress={() => setSelectedStyleKey(style.key)}
>
<Text fontSize="$2" fontWeight="$6" color={selected ? '#FFFFFF' : undefined}>
{style.name}
</Text>
</Button>
);
})}
</XStack>
{selectedStyle?.description ? (
<Text fontSize="$1" color="$color" opacity={0.75}>
{selectedStyle.description}
</Text>
) : null}
</YStack>
{originalImageUrl ? (
<YStack gap="$1">
<Text fontSize="$1" color="$color" opacity={0.7}>
{t('galleryPage.lightbox.aiMagicEditOriginalPreview', 'Original photo')}
</Text>
<img
src={originalImageUrl}
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
style={{
width: '100%',
maxHeight: 180,
objectFit: 'cover',
borderRadius: 12,
border: `1px solid ${mutedBorder}`,
}}
/>
</YStack>
) : null}
<Button
backgroundColor="$primary"
onPress={() => void startAiEdit()}
disabled={!selectedStyleKey || submitting}
>
<XStack alignItems="center" gap="$2">
{submitting ? <Loader2 size={14} className="animate-spin" color="#FFFFFF" /> : <Sparkles size={14} color="#FFFFFF" />}
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{submitting
? t('galleryPage.lightbox.aiMagicEditStarting', 'Starting...')
: t('galleryPage.lightbox.aiMagicEditGenerate', 'Generate AI edit')}
</Text>
</XStack>
</Button>
</YStack>
) : null}
{request ? (
<YStack gap="$3">
<YStack gap="$1">
<Text fontSize="$2" fontWeight="$7">
{request.style?.name ?? t('galleryPage.lightbox.aiMagicEditResultTitle', 'Result')}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7}>
{isProcessing
? t('galleryPage.lightbox.aiMagicEditProcessing', 'Generating your AI edit...')
: request.status === 'succeeded'
? t('galleryPage.lightbox.aiMagicEditReady', 'Your AI edit is ready.')
: t('galleryPage.lightbox.aiMagicEditFailed', 'AI edit could not be completed.')}
</Text>
</YStack>
{isProcessing ? (
<XStack alignItems="center" gap="$2">
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" color="$color" opacity={0.8}>
{t('galleryPage.lightbox.aiMagicEditProcessingHint', 'This can take a few seconds.')}
</Text>
</XStack>
) : null}
{isProcessing && !isOnline ? (
<Text fontSize="$2" color="$color" opacity={0.8}>
{t(
'galleryPage.lightbox.aiMagicEditOfflineHint',
'You are offline. Status updates resume automatically when connection is back.'
)}
</Text>
) : null}
{isDone && originalImageUrl ? (
<XStack gap="$2" flexWrap="wrap">
<YStack flex={1} minWidth={150} gap="$1">
<Text fontSize="$1" color="$color" opacity={0.7}>
{t('galleryPage.lightbox.aiMagicEditOriginalLabel', 'Original')}
</Text>
<img
src={originalImageUrl}
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
style={{
width: '100%',
maxHeight: 220,
objectFit: 'cover',
borderRadius: 12,
border: `1px solid ${mutedBorder}`,
}}
/>
</YStack>
<YStack flex={1} minWidth={150} gap="$1">
<Text fontSize="$1" color="$color" opacity={0.7}>
{t('galleryPage.lightbox.aiMagicEditGeneratedLabel', 'AI result')}
</Text>
<img
src={outputUrl}
alt={t('galleryPage.lightbox.aiMagicEditGeneratedAlt', 'AI generated photo')}
style={{
width: '100%',
maxHeight: 220,
objectFit: 'cover',
borderRadius: 12,
border: `1px solid ${mutedBorder}`,
}}
/>
</YStack>
</XStack>
) : null}
{requestError ? (
<Text fontSize="$2" color="#FCA5A5">{requestError}</Text>
) : null}
{(request.status === 'failed' || request.status === 'blocked' || request.status === 'canceled') && request.failure_message ? (
<Text fontSize="$2" color="#FCA5A5">{request.failure_message}</Text>
) : null}
<XStack gap="$2" flexWrap="wrap" justifyContent="flex-end">
<Button
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={resetRequestState}
>
<XStack alignItems="center" gap="$1.5">
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('galleryPage.lightbox.aiMagicEditTryAnother', 'Try another style')}
</Text>
</XStack>
</Button>
{isDone ? (
<Button backgroundColor="$primary" onPress={downloadGenerated}>
<XStack alignItems="center" gap="$1.5">
<Download size={14} color="#FFFFFF" />
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{t('common.actions.download', 'Download')}
</Text>
</XStack>
</Button>
) : null}
</XStack>
{isDone ? (
<XStack gap="$2" flexWrap="wrap">
<Button
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={shareGeneratedNative}
>
<XStack alignItems="center" gap="$1.5">
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('share.button', 'Share')}
</Text>
</XStack>
</Button>
<Button
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={shareGeneratedWhatsApp}
>
<XStack alignItems="center" gap="$1.5">
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('share.whatsapp', 'WhatsApp')}
</Text>
</XStack>
</Button>
<Button
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={shareGeneratedMessages}
>
<XStack alignItems="center" gap="$1.5">
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('share.imessage', 'Messages')}
</Text>
</XStack>
</Button>
<Button
backgroundColor={mutedSurface}
borderWidth={1}
borderColor={mutedBorder}
onPress={() => void copyGeneratedLink()}
>
<XStack alignItems="center" gap="$1.5">
<Copy size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('share.copyLink', 'Copy link')}
</Text>
</XStack>
</Button>
</XStack>
) : null}
</YStack>
) : null}
</YStack>
);
return (
<Sheet open={open} onOpenChange={onOpenChange} snapPoints={[88]} position={open ? 0 : -1} modal>
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.35)' } as any)} />
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
<YStack style={{ maxHeight: '82vh', overflowY: 'auto' }}>
{content}
</YStack>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -0,0 +1 @@
export const GUEST_AI_MAGIC_EDITS_ENABLED = false;

View File

@@ -6,6 +6,7 @@ import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sp
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import ShareSheet from '../components/ShareSheet';
import AiMagicEditSheet from '../components/AiMagicEditSheet';
import { useEventData } from '../context/EventDataContext';
import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
@@ -17,6 +18,7 @@ import { buildEventPath } from '../lib/routes';
import { getBentoSurfaceTokens } from '../lib/bento';
import { usePollStats } from '../hooks/usePollStats';
import { pushGuestToast } from '../lib/toast';
import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
@@ -111,6 +113,7 @@ export default function GalleryScreen() {
url: null,
loading: false,
});
const [aiMagicEditOpen, setAiMagicEditOpen] = React.useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
const [deleteBusy, setDeleteBusy] = React.useState(false);
const [deleteConfirmMounted, setDeleteConfirmMounted] = React.useState(false);
@@ -294,6 +297,7 @@ export default function GalleryScreen() {
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
const lightboxOpen = Boolean(selectedPhotoId);
const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id)));
const hasAiStylingAccess = GUEST_AI_MAGIC_EDITS_ENABLED && Boolean(event?.capabilities?.ai_styling);
React.useEffect(() => {
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
@@ -340,6 +344,7 @@ export default function GalleryScreen() {
[searchParams, setSearchParams, token]
);
const closeLightbox = React.useCallback(() => {
setAiMagicEditOpen(false);
const next = new URLSearchParams(searchParams);
next.delete('photo');
setSearchParams(next, { replace: true });
@@ -769,6 +774,14 @@ export default function GalleryScreen() {
document.body.removeChild(link);
}, []);
const openAiMagicEdit = React.useCallback(() => {
if (!lightboxPhoto || !hasAiStylingAccess) {
return;
}
setAiMagicEditOpen(true);
}, [hasAiStylingAccess, lightboxPhoto]);
const handleTouchStart = (event: React.TouchEvent) => {
touchStartX.current = event.touches[0]?.clientX ?? null;
};
@@ -1313,6 +1326,17 @@ export default function GalleryScreen() {
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
) : null}
{lightboxPhoto && hasAiStylingAccess ? (
<Button
unstyled
paddingHorizontal="$2"
paddingVertical="$1.5"
onPress={openAiMagicEdit}
aria-label={t('galleryPage.lightbox.aiMagicEditAria', 'AI Magic Edit')}
>
<Sparkles size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
) : null}
{lightboxPhoto && canDelete ? (
<Button
unstyled
@@ -1454,6 +1478,15 @@ export default function GalleryScreen() {
onCopyLink={() => copyLink(shareSheet.url)}
variant="inline"
/>
{hasAiStylingAccess ? (
<AiMagicEditSheet
open={aiMagicEditOpen}
onOpenChange={setAiMagicEditOpen}
eventToken={token ?? null}
photoId={lightboxPhoto?.id ?? null}
originalImageUrl={lightboxPhoto?.imageUrl ?? null}
/>
) : null}
</YStack>
</YStack>
</YStack>

View File

@@ -3,12 +3,13 @@ import { useNavigate, useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2 } from 'lucide-react';
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2, Sparkles } from 'lucide-react';
import { useGesture } from '@use-gesture/react';
import { animated, to, useSpring } from '@react-spring/web';
import AppShell from '../components/AppShell';
import SurfaceCard from '../components/SurfaceCard';
import ShareSheet from '../components/ShareSheet';
import AiMagicEditSheet from '../components/AiMagicEditSheet';
import { useEventData } from '../context/EventDataContext';
import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
@@ -16,6 +17,7 @@ import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { buildEventPath } from '../lib/routes';
import { pushGuestToast } from '../lib/toast';
import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
type LightboxPhoto = {
id: number;
@@ -85,6 +87,7 @@ export default function PhotoLightboxScreen() {
url: null,
loading: false,
});
const [aiMagicEditOpen, setAiMagicEditOpen] = React.useState(false);
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
const baseSizeRef = React.useRef({ width: 0, height: 0 });
@@ -100,6 +103,7 @@ export default function PhotoLightboxScreen() {
}));
const selected = selectedIndex !== null ? photos[selectedIndex] : null;
const hasAiStylingAccess = GUEST_AI_MAGIC_EDITS_ENABLED && Boolean(event?.capabilities?.ai_styling);
const loadPage = React.useCallback(
async (nextCursor?: string | null, replace = false) => {
@@ -381,6 +385,14 @@ export default function PhotoLightboxScreen() {
document.body.removeChild(link);
}, []);
const openAiMagicEdit = React.useCallback(() => {
if (!selected || !hasAiStylingAccess) {
return;
}
setAiMagicEditOpen(true);
}, [hasAiStylingAccess, selected]);
const bind = useGesture(
{
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
@@ -642,6 +654,22 @@ export default function PhotoLightboxScreen() {
</Text>
</XStack>
</Button>
{hasAiStylingAccess ? (
<Button
unstyled
onPress={openAiMagicEdit}
paddingHorizontal="$3"
paddingVertical="$2"
aria-label={t('galleryPage.lightbox.aiMagicEditAria', 'AI Magic Edit')}
>
<XStack alignItems="center" gap="$2">
<Sparkles size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')}
</Text>
</XStack>
</Button>
) : null}
</XStack>
</XStack>
</YStack>
@@ -667,6 +695,15 @@ export default function PhotoLightboxScreen() {
onCopyLink={() => copyLink(shareSheet.url)}
variant="inline"
/>
{hasAiStylingAccess ? (
<AiMagicEditSheet
open={aiMagicEditOpen}
onOpenChange={setAiMagicEditOpen}
eventToken={token ?? null}
photoId={selected?.id ?? null}
originalImageUrl={selected?.imageUrl ?? null}
/>
) : null}
</SurfaceCard>
</YStack>
</AppShell>

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;
}

View File

@@ -71,6 +71,13 @@ export interface EventData {
live_show?: {
moderation_mode?: 'off' | 'manual' | 'trusted_only';
};
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;
};
}
export interface PackageData {

View File

@@ -105,6 +105,7 @@
"feature_no_watermark": "Fotospiel-Wasserzeichen entfernen",
"feature_custom_tasks": "Benutzerdefinierte Tasks",
"feature_advanced_analytics": "Erweiterte Analytics",
"feature_ai_styling": "AI-Styling",
"feature_priority_support": "Priorisierter Support",
"feature_limited_sharing": "Begrenztes Teilen",
"feature_no_branding": "Kein Branding",

View File

@@ -45,6 +45,7 @@ return [
'feature_no_watermark' => 'Fotospiel-Wasserzeichen entfernen',
'feature_custom_tasks' => 'Benutzerdefinierte Tasks',
'feature_advanced_analytics' => 'Erweiterte Analytics',
'feature_ai_styling' => 'AI-Styling',
'feature_priority_support' => 'Priorisierter Support',
'feature_limited_sharing' => 'Begrenztes Teilen',
'feature_no_branding' => 'Kein Branding',

View File

@@ -106,6 +106,7 @@
"feature_no_watermark": "Remove Fotospiel watermark",
"feature_custom_tasks": "Custom Tasks",
"feature_advanced_analytics": "Advanced Analytics",
"feature_ai_styling": "AI Styling",
"feature_priority_support": "Priority Support",
"feature_limited_sharing": "Limited Sharing",
"feature_no_branding": "No Branding",

View File

@@ -45,6 +45,7 @@ return [
'feature_no_watermark' => 'Remove Fotospiel watermark',
'feature_custom_tasks' => 'Custom Tasks',
'feature_advanced_analytics' => 'Advanced Analytics',
'feature_ai_styling' => 'AI Styling',
'feature_priority_support' => 'Priority Support',
'feature_limited_sharing' => 'Limited Sharing',
'feature_no_branding' => 'No Branding',

View File

@@ -0,0 +1,19 @@
@php
use Filament\Support\Enums\GridDirection;
use Filament\Support\Icons\Heroicon;
use Illuminate\View\ComponentAttributeBag;
$stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column);
@endphp
<x-filament-panels::page>
<x-filament::section heading="AI Editing Settings" :icon="Heroicon::Sparkles">
<div {{ $stacked }}>
{{ $this->form }}
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
Save settings
</x-filament::button>
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\EventPublicAiEditController;
use App\Http\Controllers\Api\EventPublicController;
use App\Http\Controllers\Api\HelpController;
use App\Http\Controllers\Api\LegalController;
@@ -14,6 +15,7 @@ use App\Http\Controllers\Api\Support\SupportTenantActionsController;
use App\Http\Controllers\Api\Support\SupportTokenController;
use App\Http\Controllers\Api\Support\SupportWatermarkSettingsController;
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
use App\Http\Controllers\Api\Tenant\AiEditController;
use App\Http\Controllers\Api\Tenant\DashboardController;
use App\Http\Controllers\Api\Tenant\DataExportController;
use App\Http\Controllers\Api\Tenant\EmotionController;
@@ -194,9 +196,20 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
Route::delete('/photos/{id}/like', [EventPublicController::class, 'unlike'])->name('photos.unlike');
Route::get('/events/{token}/ai-styles', [EventPublicAiEditController::class, 'styles'])
->middleware('throttle:ai-edit-guest-status')
->name('events.ai-styles.index');
Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink'])
->whereNumber('photo')
->name('photos.share');
Route::post('/events/{token}/photos/{photo}/ai-edits', [EventPublicAiEditController::class, 'store'])
->whereNumber('photo')
->middleware('throttle:ai-edit-guest-submit')
->name('events.photos.ai-edits.store');
Route::get('/events/{token}/ai-edits/{requestId}', [EventPublicAiEditController::class, 'show'])
->whereNumber('requestId')
->middleware('throttle:ai-edit-guest-status')
->name('events.ai-edits.show');
Route::delete('/events/{token}/photos/{photo}', [EventPublicController::class, 'destroyPhoto'])
->whereNumber('photo')
->name('events.photos.destroy');
@@ -351,6 +364,25 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats');
});
Route::prefix('ai-edits')->group(function () {
Route::get('/', [AiEditController::class, 'index'])
->middleware('throttle:ai-edit-tenant-status')
->name('tenant.events.ai-edits.index');
Route::get('/summary', [AiEditController::class, 'summary'])
->middleware('throttle:ai-edit-tenant-status')
->name('tenant.events.ai-edits.summary');
Route::post('/', [AiEditController::class, 'store'])
->middleware('throttle:ai-edit-tenant-submit')
->name('tenant.events.ai-edits.store');
Route::get('{aiEditRequest}', [AiEditController::class, 'show'])
->whereNumber('aiEditRequest')
->middleware('throttle:ai-edit-tenant-status')
->name('tenant.events.ai-edits.show');
});
Route::get('ai-styles', [AiEditController::class, 'styles'])
->middleware('throttle:ai-edit-tenant-status')
->name('tenant.events.ai-styles.index');
Route::prefix('photobooth')->middleware('tenant.admin')->group(function () {
Route::get('/', [PhotoboothController::class, 'show'])->name('tenant.events.photobooth.show');
Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');

View File

@@ -0,0 +1,147 @@
<?php
namespace Tests\Feature\Ai;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Photo;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class AiEditingDataModelTest extends TestCase
{
use RefreshDatabase;
public function test_ai_tables_exist_with_expected_key_columns(): void
{
$this->assertTrue(Schema::hasTable('ai_styles'));
$this->assertTrue(Schema::hasTable('ai_edit_requests'));
$this->assertTrue(Schema::hasTable('ai_edit_outputs'));
$this->assertTrue(Schema::hasTable('ai_provider_runs'));
$this->assertTrue(Schema::hasTable('ai_usage_ledgers'));
foreach ([
'key',
'provider',
'provider_model',
'requires_source_image',
'is_premium',
] as $column) {
$this->assertTrue(Schema::hasColumn('ai_styles', $column));
}
foreach ([
'tenant_id',
'event_id',
'photo_id',
'style_id',
'provider',
'idempotency_key',
'safety_state',
'status',
] as $column) {
$this->assertTrue(Schema::hasColumn('ai_edit_requests', $column));
}
foreach ([
'request_id',
'provider',
'provider_task_id',
'request_payload',
'response_payload',
'cost_usd',
] as $column) {
$this->assertTrue(Schema::hasColumn('ai_provider_runs', $column));
}
}
public function test_ai_edit_flow_records_request_run_output_and_usage_with_relations(): void
{
$photo = Photo::factory()->create();
$event = $photo->event;
$tenant = $event->tenant;
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$style = AiStyle::query()->create([
'key' => 'bg-colosseum',
'name' => 'Colosseum Background',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'requested_by_user_id' => $user->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_QUEUED,
'safety_state' => 'pending',
'prompt' => 'Replace background with Colosseum in Rome.',
'idempotency_key' => 'req-'.$photo->id.'-1',
'queued_at' => now(),
'metadata' => ['source' => 'guest_pwa'],
]);
$run = AiProviderRun::query()->create([
'request_id' => $request->id,
'provider' => 'runware',
'attempt' => 1,
'provider_task_id' => 'task-123',
'status' => AiProviderRun::STATUS_RUNNING,
'request_payload' => ['positivePrompt' => 'Rome Colosseum'],
'response_payload' => ['status' => 'processing'],
'started_at' => now(),
]);
$output = AiEditOutput::query()->create([
'request_id' => $request->id,
'storage_disk' => 'public',
'storage_path' => 'ai/outputs/final.jpg',
'mime_type' => 'image/jpeg',
'width' => 1024,
'height' => 1024,
'is_primary' => true,
'generated_at' => now(),
'metadata' => ['variant' => 'v1'],
]);
$ledger = AiUsageLedger::query()->create([
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'request_id' => $request->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.015,
'amount_usd' => 0.015,
'recorded_at' => now(),
'metadata' => ['provider' => 'runware'],
]);
$request->refresh();
$run->refresh();
$output->refresh();
$ledger->refresh();
$this->assertSame($style->id, $request->style?->id);
$this->assertSame($tenant->id, $request->tenant?->id);
$this->assertSame($event->id, $request->event?->id);
$this->assertSame($photo->id, $request->photo?->id);
$this->assertSame(1, $request->providerRuns()->count());
$this->assertSame(1, $request->outputs()->count());
$this->assertSame(1, $request->usageLedgers()->count());
$this->assertIsArray($run->request_payload);
$this->assertIsArray($run->response_payload);
$this->assertIsArray($ledger->metadata);
$this->assertGreaterThan(0, (float) $ledger->amount_usd);
}
}

View File

@@ -0,0 +1,720 @@
<?php
namespace Tests\Feature\Api\Event;
use App\Models\AiEditingSetting;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Models\Photo;
use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventAiEditControllerTest extends TestCase
{
use RefreshDatabase;
public function test_guest_can_create_and_fetch_ai_edit_request_with_token(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'ghibli-soft',
'name' => 'Soft Animation',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai'])
->getAttribute('plain_token');
$create = $this->withHeaders(['X-Device-Id' => 'guest-device-1'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'style_key' => $style->key,
'prompt' => 'Transform into animation style while keeping faces.',
'idempotency_key' => 'guest-edit-1',
]);
$create->assertCreated()
->assertJsonPath('data.event_id', $event->id)
->assertJsonPath('data.photo_id', $photo->id)
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED)
->assertJsonPath('data.style.key', $style->key)
->assertJsonPath('duplicate', false);
$requestId = (int) $create->json('data.id');
$this->assertGreaterThan(0, $requestId);
$duplicate = $this->withHeaders(['X-Device-Id' => 'guest-device-1'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'style_key' => $style->key,
'prompt' => 'Transform into animation style while keeping faces.',
'idempotency_key' => 'guest-edit-1',
]);
$duplicate->assertOk()
->assertJsonPath('duplicate', true)
->assertJsonPath('data.id', $requestId);
$show = $this->withHeaders(['X-Device-Id' => 'guest-device-1'])
->getJson("/api/v1/events/{$token}/ai-edits/{$requestId}");
$show->assertOk()
->assertJsonPath('data.id', $requestId)
->assertJsonPath('data.event_id', $event->id)
->assertJsonPath('data.photo_id', $photo->id);
}
public function test_guest_prompt_is_blocked_by_safety_policy(): void
{
AiEditingSetting::flushCache();
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
['blocked_terms' => ['nude', 'explicit']]
));
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-blocked'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-2'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Create an explicit studio image.',
'idempotency_key' => 'guest-edit-blocked-1',
]);
$response->assertCreated()
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
->assertJsonPath('data.safety_state', 'blocked')
->assertJsonPath('data.failure_code', 'prompt_policy_blocked')
->assertJsonPath('data.safety_reasons.0', 'prompt_blocked_term');
}
public function test_guest_cannot_create_ai_edit_when_feature_is_disabled(): void
{
AiEditingSetting::flushCache();
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
[
'is_enabled' => false,
'status_message' => 'AI editing is temporarily paused.',
]
));
$event = Event::factory()->create([
'status' => 'published',
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-disabled'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-disabled'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize the photo in watercolor style.',
'idempotency_key' => 'guest-edit-disabled-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_disabled');
}
public function test_guest_cannot_create_ai_edit_when_entitlement_is_missing(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachLockedEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-locked'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-locked'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize with cinematic atmosphere.',
'idempotency_key' => 'guest-edit-locked-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_locked')
->assertJsonPath('error.meta.required_feature', 'ai_styling')
->assertJsonPath('error.meta.addon_keys.0', 'ai_styling_unlock');
}
public function test_guest_can_create_ai_edit_when_ai_addon_is_completed(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-addon'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-addon'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize photo with warm colors.',
'idempotency_key' => 'guest-addon-enabled-1',
]);
$response->assertCreated()
->assertJsonPath('duplicate', false)
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED);
}
public function test_guest_cannot_create_ai_edit_when_ai_addon_is_expired(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now()->subDays(5),
'metadata' => [
'entitlements' => [
'features' => ['ai_styling'],
'expires_at' => now()->subHour()->toIso8601String(),
],
],
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-addon-expired'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-addon-expired'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize photo with warm colors.',
'idempotency_key' => 'guest-addon-expired-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_locked');
}
public function test_guest_can_list_active_ai_styles_when_entitled(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$this->updateEventAiSettings($event, [
'enabled' => true,
'allow_custom_prompt' => false,
'allowed_style_keys' => ['guest-style-allowed'],
'policy_message' => 'Please use the curated style list.',
]);
$allowed = AiStyle::query()->create([
'key' => 'guest-style-allowed',
'name' => 'Guest Allowed',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'sort' => 2,
]);
AiStyle::query()->create([
'key' => 'guest-style-blocked',
'name' => 'Guest Blocked',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'sort' => 1,
]);
AiStyle::query()->create([
'key' => 'guest-style-inactive',
'name' => 'Guest Inactive',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => false,
'sort' => 1,
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-styles'])
->getAttribute('plain_token');
$response = $this->getJson("/api/v1/events/{$token}/ai-styles");
$response->assertOk()
->assertJsonPath('data.0.id', $allowed->id)
->assertJsonCount(1, 'data')
->assertJsonPath('meta.required_feature', 'ai_styling')
->assertJsonPath('meta.allow_custom_prompt', false)
->assertJsonPath('meta.allowed_style_keys.0', 'guest-style-allowed')
->assertJsonPath('meta.policy_message', 'Please use the curated style list.');
}
public function test_guest_styles_exclude_premium_style_when_event_is_entitled_via_addon(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$basicStyle = AiStyle::query()->create([
'key' => 'guest-addon-basic-style',
'name' => 'Addon Basic Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'is_premium' => false,
]);
AiStyle::query()->create([
'key' => 'guest-addon-premium-style',
'name' => 'Addon Premium Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'is_premium' => true,
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-addon-style-filter'])
->getAttribute('plain_token');
$response = $this->getJson("/api/v1/events/{$token}/ai-styles");
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.key', $basicStyle->key);
}
public function test_guest_cannot_create_premium_style_edit_when_event_is_entitled_via_addon(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$premiumStyle = AiStyle::query()->create([
'key' => 'guest-addon-premium-submit',
'name' => 'Addon Premium Submit',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
'is_premium' => true,
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-addon-premium-submit'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-addon-premium'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'style_key' => $premiumStyle->key,
'prompt' => 'Apply premium style.',
'idempotency_key' => 'guest-addon-premium-style-submit-1',
]);
$response->assertUnprocessable()
->assertJsonPath('error.code', 'style_not_allowed');
}
public function test_guest_can_create_premium_style_edit_when_event_is_entitled_via_package(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$premiumStyle = AiStyle::query()->create([
'key' => 'guest-package-premium-submit',
'name' => 'Package Premium Submit',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
'is_premium' => true,
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-package-premium-submit'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-package-premium'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'style_key' => $premiumStyle->key,
'prompt' => 'Apply premium style.',
'idempotency_key' => 'guest-package-premium-style-submit-1',
]);
$response->assertCreated()
->assertJsonPath('data.style.key', $premiumStyle->key)
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED);
}
public function test_guest_styles_filter_required_package_features_from_style_metadata(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$availableStyle = AiStyle::query()->create([
'key' => 'guest-required-feature-available',
'name' => 'Available Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
]);
AiStyle::query()->create([
'key' => 'guest-required-feature-blocked',
'name' => 'Blocked Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'metadata' => [
'entitlements' => [
'required_package_features' => ['advanced_analytics'],
],
],
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-required-feature-filter'])
->getAttribute('plain_token');
$response = $this->getJson("/api/v1/events/{$token}/ai-styles");
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.key', $availableStyle->key);
}
public function test_guest_ai_styles_endpoint_is_locked_without_entitlement(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachLockedEventPackage($event);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-styles-locked'])
->getAttribute('plain_token');
$response = $this->getJson("/api/v1/events/{$token}/ai-styles");
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_locked')
->assertJsonPath('error.meta.required_feature', 'ai_styling')
->assertJsonPath('error.meta.addon_keys.0', 'ai_styling_unlock');
}
public function test_guest_returns_idempotency_conflict_for_payload_mismatch(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-idempotency'])
->getAttribute('plain_token');
$this->withHeaders(['X-Device-Id' => 'guest-device-idempotency'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Create version A.',
'idempotency_key' => 'guest-idempotency-conflict-1',
])->assertCreated();
$conflict = $this->withHeaders(['X-Device-Id' => 'guest-device-idempotency'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Create version B.',
'idempotency_key' => 'guest-idempotency-conflict-1',
]);
$conflict->assertStatus(409)
->assertJsonPath('error.code', 'idempotency_conflict');
}
public function test_guest_cannot_fetch_edit_request_from_another_device(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-device-scope'])
->getAttribute('plain_token');
$create = $this->withHeaders(['X-Device-Id' => 'guest-device-owner'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize photo.',
'idempotency_key' => 'guest-device-scope-1',
]);
$create->assertCreated();
$requestId = (int) $create->json('data.id');
$forbidden = $this->withHeaders(['X-Device-Id' => 'guest-device-other'])
->getJson("/api/v1/events/{$token}/ai-edits/{$requestId}");
$forbidden->assertForbidden()
->assertJsonPath('error.code', 'forbidden_request_scope');
}
public function test_guest_submit_endpoint_enforces_rate_limit(): void
{
config([
'ai-editing.abuse.guest_submit_per_minute' => 1,
'ai-editing.abuse.guest_submit_per_hour' => 50,
]);
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-rate-limit'])
->getAttribute('plain_token');
$first = $this->withHeaders(['X-Device-Id' => 'guest-device-rate-limit'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'First request.',
'idempotency_key' => 'guest-rate-limit-1',
]);
$first->assertCreated();
$second = $this->withHeaders(['X-Device-Id' => 'guest-device-rate-limit'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Second request.',
'idempotency_key' => 'guest-rate-limit-2',
]);
$second->assertStatus(429);
}
public function test_guest_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$this->updateEventAiSettings($event, [
'enabled' => false,
'policy_message' => 'AI edits are disabled for this event.',
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-event-disabled'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-disabled-event'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize this image.',
'idempotency_key' => 'guest-event-disabled-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'event_feature_disabled')
->assertJsonPath('error.message', 'AI edits are disabled for this event.');
}
public function test_guest_cannot_create_ai_edit_with_style_outside_allowlist(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$allowed = AiStyle::query()->create([
'key' => 'guest-style-allowlisted',
'name' => 'Allowlisted',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$blocked = AiStyle::query()->create([
'key' => 'guest-style-not-allowlisted',
'name' => 'Not allowlisted',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$this->updateEventAiSettings($event, [
'enabled' => true,
'allow_custom_prompt' => false,
'allowed_style_keys' => [$allowed->key],
'policy_message' => 'Only curated styles are enabled.',
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-style-allowlist'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-allowlist'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'style_key' => $blocked->key,
'prompt' => 'Apply blocked style.',
'idempotency_key' => 'guest-style-allowlist-block-1',
]);
$response->assertUnprocessable()
->assertJsonPath('error.code', 'style_not_allowed')
->assertJsonPath('error.message', 'Only curated styles are enabled.')
->assertJsonPath('error.meta.allowed_style_keys.0', $allowed->key);
}
private function attachEntitledEventPackage(Event $event): EventPackage
{
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads', 'ai_styling'],
]);
return EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
}
private function attachLockedEventPackage(Event $event): EventPackage
{
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads', 'custom_tasks'],
]);
return EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
}
private function updateEventAiSettings(Event $event, array $aiSettings): void
{
$settings = is_array($event->settings) ? $event->settings : [];
$settings['ai_editing'] = $aiSettings;
$event->update(['settings' => $settings]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Api\Event;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventPublicCapabilitiesTest extends TestCase
{
use RefreshDatabase;
public function test_event_response_hides_ai_capability_when_not_entitled(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachPackage($event, ['basic_uploads', 'custom_tasks']);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'public-ai-capability-locked'])
->token;
$response = $this->withHeader('X-Device-Id', 'public-ai-capability-locked-device')
->getJson("/api/v1/events/{$token}");
$response->assertOk()
->assertJsonPath('capabilities.ai_styling', false)
->assertJsonPath('capabilities.ai_styling_granted_by', null);
}
public function test_event_response_exposes_ai_capability_when_package_includes_feature(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachPackage($event, ['basic_uploads', 'ai_styling']);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'public-ai-capability-package'])
->token;
$response = $this->withHeader('X-Device-Id', 'public-ai-capability-package-device')
->getJson("/api/v1/events/{$token}");
$response->assertOk()
->assertJsonPath('capabilities.ai_styling', true)
->assertJsonPath('capabilities.ai_styling_granted_by', 'package');
}
public function test_event_response_exposes_ai_capability_when_addon_unlock_is_completed(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$eventPackage = $this->attachPackage($event, ['basic_uploads', 'custom_tasks']);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'public-ai-capability-addon'])
->token;
$response = $this->withHeader('X-Device-Id', 'public-ai-capability-addon-device')
->getJson("/api/v1/events/{$token}");
$response->assertOk()
->assertJsonPath('capabilities.ai_styling', true)
->assertJsonPath('capabilities.ai_styling_granted_by', 'addon');
}
/**
* @param array<int, string> $features
*/
private function attachPackage(Event $event, array $features): EventPackage
{
$package = Package::factory()->endcustomer()->create([
'features' => $features,
]);
return EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
}
}

View File

@@ -99,4 +99,130 @@ class BillingAddonHistoryTest extends TenantTestCase
$response->assertJsonPath('data.0.event.slug', $event->slug);
$response->assertJsonPath('data.1.id', $firstAddon->id);
}
public function test_tenant_can_filter_addon_history_by_event_scope(): void
{
$package = Package::factory()->endcustomer()->create();
$firstEvent = Event::factory()->for($this->tenant)->create([
'slug' => 'first-event',
'name' => ['de' => 'Erstes Event', 'en' => 'First Event'],
]);
$secondEvent = Event::factory()->for($this->tenant)->create([
'slug' => 'second-event',
'name' => ['de' => 'Zweites Event', 'en' => 'Second Event'],
]);
$firstEventPackage = EventPackage::create([
'event_id' => $firstEvent->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now()->subWeek(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(20),
]);
$secondEventPackage = EventPackage::create([
'event_id' => $secondEvent->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now()->subWeek(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(20),
]);
$scopedAddon = EventPackageAddon::create([
'event_package_id' => $firstEventPackage->id,
'event_id' => $firstEvent->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_photos_100',
'quantity' => 1,
'status' => 'completed',
'amount' => 29.00,
'currency' => 'EUR',
'purchased_at' => now(),
]);
EventPackageAddon::create([
'event_package_id' => $secondEventPackage->id,
'event_id' => $secondEvent->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_guests_100',
'quantity' => 1,
'status' => 'completed',
'amount' => 39.00,
'currency' => 'EUR',
'purchased_at' => now(),
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/billing/addons?event_id={$firstEvent->id}");
$response->assertOk();
$response->assertJsonPath('meta.total', 1);
$response->assertJsonPath('data.0.id', $scopedAddon->id);
$response->assertJsonPath('meta.scope.type', 'event');
$response->assertJsonPath('meta.scope.event.id', $firstEvent->id);
$response->assertJsonPath('meta.scope.event.slug', 'first-event');
}
public function test_tenant_can_filter_addon_history_by_event_slug_and_status(): void
{
$package = Package::factory()->endcustomer()->create();
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'winter-ball',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now()->subWeek(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(20),
]);
EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_photos_100',
'quantity' => 1,
'status' => 'pending',
'amount' => 19.00,
'currency' => 'EUR',
'purchased_at' => now()->subHour(),
]);
$completedAddon = EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_guests_100',
'quantity' => 1,
'status' => 'completed',
'amount' => 29.00,
'currency' => 'EUR',
'purchased_at' => now(),
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons?event_slug=winter-ball&status=completed');
$response->assertOk();
$response->assertJsonPath('meta.total', 1);
$response->assertJsonPath('data.0.id', $completedAddon->id);
$response->assertJsonPath('data.0.status', 'completed');
$response->assertJsonPath('meta.scope.type', 'event');
$response->assertJsonPath('meta.scope.event.slug', 'winter-ball');
}
public function test_tenant_gets_not_found_for_unknown_event_scope_filter(): void
{
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons?event_slug=missing-event');
$response->assertStatus(404);
$response->assertJsonPath('message', 'Event scope not found.');
}
}

View File

@@ -48,5 +48,43 @@ class EventAddonsSummaryTest extends TenantTestCase
$response->assertOk();
$response->assertJsonPath('data.addons.0.key', 'extra_guests_100');
$response->assertJsonPath('data.addons.0.extra_guests', 100);
$response->assertJsonPath('data.capabilities.ai_styling', false);
}
public function test_event_resource_reports_ai_styling_capability_when_addon_is_completed(): void
{
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}");
$response->assertOk();
$response->assertJsonPath('data.capabilities.ai_styling', true);
$response->assertJsonPath('data.capabilities.ai_styling_granted_by', 'addon');
}
}

View File

@@ -0,0 +1,731 @@
<?php
namespace Tests\Feature\Api\Tenant;
use App\Models\AiEditingSetting;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Models\Photo;
use Tests\Feature\Tenant\TenantTestCase;
class TenantAiEditControllerTest extends TenantTestCase
{
public function test_tenant_admin_can_create_list_and_show_ai_edit_requests(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'colosseum-bg',
'name' => 'Colosseum',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$create = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Place group photo in Rome.',
'idempotency_key' => 'tenant-edit-1',
]);
$create->assertCreated()
->assertJsonPath('data.event_id', $event->id)
->assertJsonPath('data.photo_id', $photo->id)
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED)
->assertJsonPath('data.style.id', $style->id)
->assertJsonPath('duplicate', false);
$requestId = (int) $create->json('data.id');
$this->assertGreaterThan(0, $requestId);
$index = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits");
$index->assertOk()
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.id', $requestId);
$show = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits/{$requestId}");
$show->assertOk()
->assertJsonPath('data.id', $requestId)
->assertJsonPath('data.event_id', $event->id);
}
public function test_tenant_prompt_is_blocked_by_safety_policy(): void
{
AiEditingSetting::flushCache();
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
['blocked_terms' => ['violence', 'weapon']]
));
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'blocked-style',
'name' => 'Blocked Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Add weapon effects to the scene.',
'idempotency_key' => 'tenant-edit-blocked-1',
]);
$response->assertCreated()
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
->assertJsonPath('data.safety_state', 'blocked')
->assertJsonPath('data.failure_code', 'prompt_policy_blocked')
->assertJsonPath('data.safety_reasons.0', 'prompt_blocked_term');
}
public function test_tenant_cannot_create_ai_edit_when_feature_is_disabled(): void
{
AiEditingSetting::flushCache();
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
['is_enabled' => false]
));
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'disabled-style',
'name' => 'Disabled Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Apply style transfer.',
'idempotency_key' => 'tenant-edit-disabled-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_disabled');
}
public function test_tenant_cannot_create_ai_edit_when_entitlement_is_missing(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachLockedEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'locked-style',
'name' => 'Locked Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Apply style transfer.',
'idempotency_key' => 'tenant-edit-locked-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_locked')
->assertJsonPath('error.meta.required_feature', 'ai_styling')
->assertJsonPath('error.meta.addon_keys.0', 'ai_styling_unlock');
}
public function test_tenant_can_create_ai_edit_when_ai_addon_is_completed(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'addon-enabled-style',
'name' => 'Addon Enabled',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Add a realistic city background.',
'idempotency_key' => 'tenant-addon-entitled-1',
]);
$response->assertCreated()
->assertJsonPath('duplicate', false)
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED);
}
public function test_tenant_cannot_create_ai_edit_when_ai_addon_is_expired(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now()->subDays(10),
'metadata' => [
'entitlements' => [
'features' => ['ai_styling'],
'expires_at' => now()->subDay()->toIso8601String(),
],
],
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'addon-expired-style',
'name' => 'Addon Expired',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Add a realistic city background.',
'idempotency_key' => 'tenant-addon-expired-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_locked');
}
public function test_tenant_can_list_active_ai_styles_when_entitled(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$this->updateEventAiSettings($event, [
'enabled' => false,
'allow_custom_prompt' => false,
'allowed_style_keys' => ['tenant-style-active'],
'policy_message' => 'AI is currently paused for this event.',
]);
$active = AiStyle::query()->create([
'key' => 'tenant-style-active',
'name' => 'Tenant Active',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'sort' => 2,
]);
AiStyle::query()->create([
'key' => 'tenant-style-inactive',
'name' => 'Tenant Inactive',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => false,
'sort' => 1,
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-styles");
$response->assertOk()
->assertJsonPath('data.0.id', $active->id)
->assertJsonCount(1, 'data')
->assertJsonPath('meta.required_feature', 'ai_styling')
->assertJsonPath('meta.event_enabled', false)
->assertJsonPath('meta.allow_custom_prompt', false)
->assertJsonPath('meta.allowed_style_keys.0', 'tenant-style-active')
->assertJsonPath('meta.policy_message', 'AI is currently paused for this event.');
}
public function test_tenant_styles_exclude_premium_style_when_event_is_entitled_via_addon(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$basicStyle = AiStyle::query()->create([
'key' => 'tenant-addon-basic-style',
'name' => 'Tenant Addon Basic',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'is_premium' => false,
]);
AiStyle::query()->create([
'key' => 'tenant-addon-premium-style',
'name' => 'Tenant Addon Premium',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'is_premium' => true,
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-styles");
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.key', $basicStyle->key);
}
public function test_tenant_cannot_create_premium_style_edit_when_event_is_entitled_via_addon(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$eventPackage = $this->attachLockedEventPackage($event);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$premiumStyle = AiStyle::query()->create([
'key' => 'tenant-addon-premium-submit',
'name' => 'Tenant Addon Premium Submit',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
'is_premium' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $premiumStyle->id,
'prompt' => 'Apply premium style.',
'idempotency_key' => 'tenant-addon-premium-style-submit-1',
]);
$response->assertUnprocessable()
->assertJsonPath('error.code', 'style_not_allowed');
}
public function test_tenant_can_create_premium_style_edit_when_event_is_entitled_via_package(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$premiumStyle = AiStyle::query()->create([
'key' => 'tenant-package-premium-submit',
'name' => 'Tenant Package Premium Submit',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
'is_premium' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $premiumStyle->id,
'prompt' => 'Apply premium style.',
'idempotency_key' => 'tenant-package-premium-style-submit-1',
]);
$response->assertCreated()
->assertJsonPath('data.style.id', $premiumStyle->id)
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED);
}
public function test_tenant_ai_styles_endpoint_is_locked_without_entitlement(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachLockedEventPackage($event);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-styles");
$response->assertForbidden()
->assertJsonPath('error.code', 'feature_locked')
->assertJsonPath('error.meta.required_feature', 'ai_styling')
->assertJsonPath('error.meta.addon_keys.0', 'ai_styling_unlock');
}
public function test_tenant_returns_idempotency_conflict_for_payload_mismatch(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$primaryStyle = AiStyle::query()->create([
'key' => 'tenant-idempotency-style-a',
'name' => 'Style A',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$secondaryStyle = AiStyle::query()->create([
'key' => 'tenant-idempotency-style-b',
'name' => 'Style B',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $primaryStyle->id,
'prompt' => 'Create version A.',
'idempotency_key' => 'tenant-idempotency-conflict-1',
])->assertCreated();
$conflict = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $secondaryStyle->id,
'prompt' => 'Create version B.',
'idempotency_key' => 'tenant-idempotency-conflict-1',
]);
$conflict->assertStatus(409)
->assertJsonPath('error.code', 'idempotency_conflict');
}
public function test_tenant_submit_endpoint_enforces_rate_limit(): void
{
config([
'ai-editing.abuse.tenant_submit_per_minute' => 1,
'ai-editing.abuse.tenant_submit_per_hour' => 50,
]);
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'tenant-rate-limit-style',
'name' => 'Rate Limit',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$first = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'First request.',
'idempotency_key' => 'tenant-rate-limit-1',
]);
$first->assertCreated();
$second = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Second request.',
'idempotency_key' => 'tenant-rate-limit-2',
]);
$second->assertStatus(429);
}
public function test_tenant_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$this->updateEventAiSettings($event, [
'enabled' => false,
'policy_message' => 'AI editing is disabled for this event by the organizer.',
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'tenant-event-disabled-style',
'name' => 'Disabled',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Apply AI style.',
'idempotency_key' => 'tenant-event-disabled-ai-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'event_feature_disabled')
->assertJsonPath('error.message', 'AI editing is disabled for this event by the organizer.');
}
public function test_tenant_cannot_create_ai_edit_with_style_outside_allowlist(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$allowedStyle = AiStyle::query()->create([
'key' => 'tenant-allowed-style',
'name' => 'Allowed',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$blockedStyle = AiStyle::query()->create([
'key' => 'tenant-blocked-style',
'name' => 'Blocked',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$this->updateEventAiSettings($event, [
'enabled' => true,
'allowed_style_keys' => [$allowedStyle->key],
'policy_message' => 'Only curated styles are enabled for this event.',
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $blockedStyle->id,
'prompt' => 'Apply blocked style.',
'idempotency_key' => 'tenant-style-allowlist-block-1',
]);
$response->assertUnprocessable()
->assertJsonPath('error.code', 'style_not_allowed')
->assertJsonPath('error.message', 'Only curated styles are enabled for this event.')
->assertJsonPath('error.meta.allowed_style_keys.0', $allowedStyle->key);
}
public function test_tenant_can_fetch_ai_usage_summary(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'tenant-summary-style',
'name' => 'Summary',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
AiEditRequest::query()->create([
'tenant_id' => $this->tenant->id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'approved',
'prompt' => 'Request A',
'idempotency_key' => 'tenant-summary-1',
'queued_at' => now()->subMinutes(3),
'completed_at' => now()->subMinutes(2),
]);
AiEditRequest::query()->create([
'tenant_id' => $this->tenant->id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_FAILED,
'safety_state' => 'approved',
'prompt' => 'Request B',
'idempotency_key' => 'tenant-summary-2',
'queued_at' => now()->subMinutes(1),
'completed_at' => now(),
'failure_code' => 'provider_error',
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits/summary");
$response->assertOk()
->assertJsonPath('data.event_id', $event->id)
->assertJsonPath('data.total', 2)
->assertJsonPath('data.status_counts.succeeded', 1)
->assertJsonPath('data.status_counts.failed', 1)
->assertJsonPath('data.failed_total', 1);
}
private function attachEntitledEventPackage(Event $event): EventPackage
{
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads', 'ai_styling'],
]);
return EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
}
private function attachLockedEventPackage(Event $event): EventPackage
{
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads', 'custom_tasks'],
]);
return EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
}
private function updateEventAiSettings(Event $event, array $aiSettings): void
{
$settings = is_array($event->settings) ? $event->settings : [];
$settings['ai_editing'] = $aiSettings;
$event->update(['settings' => $settings]);
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace Tests\Feature\Jobs;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditingSetting;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Photo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProcessAiEditRequestTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
AiEditingSetting::flushCache();
}
public function test_it_processes_ai_edit_request_with_fake_runware_provider(): void
{
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
[
'runware_mode' => 'fake',
'queue_auto_dispatch' => false,
]
));
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'fake-style',
'name' => 'Fake Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_QUEUED,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'job-fake-1',
'queued_at' => now(),
]);
ProcessAiEditRequest::dispatchSync($request->id);
$request->refresh();
$this->assertSame(AiEditRequest::STATUS_SUCCEEDED, $request->status);
$this->assertNotNull($request->started_at);
$this->assertNotNull($request->completed_at);
$this->assertSame(1, $request->outputs()->count());
$this->assertSame(1, $request->providerRuns()->count());
$this->assertSame('succeeded', $request->providerRuns()->first()?->status);
$this->assertSame(1, $request->usageLedgers()->count());
$this->assertSame(AiUsageLedger::TYPE_DEBIT, $request->usageLedgers()->first()?->entry_type);
$this->assertSame('unentitled', $request->usageLedgers()->first()?->package_context);
ProcessAiEditRequest::dispatchSync($request->id);
$request->refresh();
$this->assertSame(1, $request->usageLedgers()->count());
}
public function test_it_marks_request_failed_when_runware_is_not_configured(): void
{
config([
'services.runware.api_key' => null,
]);
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
[
'runware_mode' => 'live',
'queue_auto_dispatch' => false,
]
));
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'live-style',
'name' => 'Live Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_QUEUED,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'job-live-1',
'queued_at' => now(),
]);
ProcessAiEditRequest::dispatchSync($request->id);
$request->refresh();
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
$this->assertSame('provider_not_configured', $request->failure_code);
$this->assertNotNull($request->completed_at);
$this->assertSame(0, $request->outputs()->count());
$this->assertSame(1, $request->providerRuns()->count());
$this->assertSame('failed', $request->providerRuns()->first()?->status);
}
public function test_it_blocks_request_when_provider_flags_output_as_unsafe(): void
{
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
[
'runware_mode' => 'fake',
'queue_auto_dispatch' => false,
]
));
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'unsafe-style',
'name' => 'Unsafe Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_QUEUED,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'job-fake-unsafe-1',
'queued_at' => now(),
'metadata' => ['fake_nsfw' => true],
]);
ProcessAiEditRequest::dispatchSync($request->id);
$request->refresh();
$this->assertSame(AiEditRequest::STATUS_BLOCKED, $request->status);
$this->assertSame('blocked', $request->safety_state);
$this->assertSame('output_policy_blocked', $request->failure_code);
$this->assertSame(['provider_nsfw_content'], $request->safety_reasons);
$this->assertNotNull($request->completed_at);
$this->assertSame(0, $request->outputs()->count());
$this->assertSame(1, $request->providerRuns()->count());
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use App\Filament\Resources\PackageResource;
use Tests\TestCase;
class PackageResourceFeatureLabelTest extends TestCase
{
public function test_it_formats_ai_styling_feature_label_for_display(): void
{
$formatted = PackageResource::formatFeaturesForDisplay(['ai_styling', 'custom_branding']);
$this->assertSame('AI-Styling, Eigenes Branding', $formatted);
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace Tests\Unit\Services;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Services\AiEditing\AiStyleAccessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AiStyleAccessServiceTest extends TestCase
{
use RefreshDatabase;
public function test_package_entitlement_can_use_premium_style(): void
{
$event = Event::factory()->create();
$this->attachPackage($event, ['basic_uploads', 'ai_styling']);
$style = AiStyle::query()->create([
'key' => 'premium-package-style',
'name' => 'Premium Package Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'is_premium' => true,
]);
$service = app(AiStyleAccessService::class);
$this->assertTrue($service->canUseStyle($event->fresh(), $style));
}
public function test_addon_entitlement_cannot_use_premium_style_by_default(): void
{
$event = Event::factory()->create();
$eventPackage = $this->attachPackage($event, ['basic_uploads']);
$this->attachAddon($event, $eventPackage);
$style = AiStyle::query()->create([
'key' => 'premium-addon-blocked',
'name' => 'Premium Addon Blocked',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'is_premium' => true,
]);
$service = app(AiStyleAccessService::class);
$this->assertFalse($service->canUseStyle($event->fresh(), $style));
}
public function test_addon_entitlement_can_use_premium_style_when_style_metadata_allows_it(): void
{
$event = Event::factory()->create();
$eventPackage = $this->attachPackage($event, ['basic_uploads']);
$this->attachAddon($event, $eventPackage);
$style = AiStyle::query()->create([
'key' => 'premium-addon-allowed',
'name' => 'Premium Addon Allowed',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'is_premium' => true,
'metadata' => [
'entitlements' => [
'allow_with_addon' => true,
],
],
]);
$service = app(AiStyleAccessService::class);
$this->assertTrue($service->canUseStyle($event->fresh(), $style));
}
public function test_required_package_feature_blocks_style_when_missing(): void
{
$event = Event::factory()->create();
$this->attachPackage($event, ['basic_uploads', 'ai_styling']);
$style = AiStyle::query()->create([
'key' => 'advanced-only-style',
'name' => 'Advanced Only',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'metadata' => [
'entitlements' => [
'required_package_features' => ['advanced_analytics'],
],
],
]);
$service = app(AiStyleAccessService::class);
$this->assertFalse($service->canUseStyle($event->fresh(), $style));
}
public function test_required_package_feature_allows_style_when_present(): void
{
$event = Event::factory()->create();
$this->attachPackage($event, ['basic_uploads', 'ai_styling', 'advanced_analytics']);
$style = AiStyle::query()->create([
'key' => 'advanced-available-style',
'name' => 'Advanced Available',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
'metadata' => [
'entitlements' => [
'required_package_features' => ['advanced_analytics'],
],
],
]);
$service = app(AiStyleAccessService::class);
$this->assertTrue($service->canUseStyle($event->fresh(), $style));
}
/**
* @param array<int, string> $features
*/
private function attachPackage(Event $event, array $features): EventPackage
{
$package = Package::factory()->endcustomer()->create([
'features' => $features,
]);
return EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
}
private function attachAddon(Event $event, EventPackage $eventPackage): EventPackageAddon
{
return EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace Tests\Unit\Services;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Services\AiEditing\AiStylingEntitlementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AiStylingEntitlementServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_grants_access_via_package_feature(): void
{
$service = app(AiStylingEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads', 'ai_styling'],
]);
EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
$result = $service->resolveForEvent($event->fresh());
$this->assertTrue($result['allowed']);
$this->assertSame('package', $result['granted_by']);
}
public function test_it_grants_access_via_completed_addon(): void
{
$service = app(AiStylingEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$eventPackage = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$result = $service->resolveForEvent($event->fresh());
$this->assertTrue($result['allowed']);
$this->assertSame('addon', $result['granted_by']);
}
public function test_it_denies_access_without_feature_or_completed_addon(): void
{
$service = app(AiStylingEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$eventPackage = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'pending',
]);
$result = $service->resolveForEvent($event->fresh());
$this->assertFalse($result['allowed']);
$this->assertNull($result['granted_by']);
}
public function test_it_denies_access_when_addon_is_expired_via_metadata(): void
{
$service = app(AiStylingEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$eventPackage = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now()->subDays(5),
'metadata' => [
'entitlements' => [
'features' => ['ai_styling'],
'expires_at' => now()->subDay()->toIso8601String(),
],
],
]);
$result = $service->resolveForEvent($event->fresh());
$this->assertFalse($result['allowed']);
$this->assertNull($result['granted_by']);
}
public function test_it_uses_latest_event_package_for_upgrade_and_downgrade(): void
{
$service = app(AiStylingEntitlementService::class);
$event = Event::factory()->create();
$premium = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads', 'ai_styling'],
]);
$starter = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $premium->id,
'purchased_price' => $premium->price,
'purchased_at' => now()->subDays(5),
'gallery_expires_at' => now()->addDays(30),
]);
$latest = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $starter->id,
'purchased_price' => $starter->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
$resultWithoutAddon = $service->resolveForEvent($event->fresh());
$this->assertFalse($resultWithoutAddon['allowed']);
EventPackageAddon::query()->create([
'event_package_id' => $latest->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$resultWithAddon = $service->resolveForEvent($event->fresh());
$this->assertTrue($resultWithAddon['allowed']);
$this->assertSame('addon', $resultWithAddon['granted_by']);
}
public function test_it_grants_access_via_metadata_feature_even_with_custom_addon_key(): void
{
$service = app(AiStylingEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$eventPackage = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'custom_ai_bundle',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
'metadata' => [
'entitlements' => [
'features' => ['ai_styling'],
],
],
]);
$result = $service->resolveForEvent($event->fresh());
$this->assertTrue($result['allowed']);
$this->assertSame('addon', $result['granted_by']);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Tests\Unit\Services;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Models\Photo;
use App\Services\AiEditing\AiUsageLedgerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AiUsageLedgerServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_records_package_included_context_when_feature_is_in_package(): void
{
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['ai_styling'],
]);
EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(14),
]);
$style = AiStyle::query()->create([
'key' => 'ledger-style-package',
'name' => 'Ledger Package',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'prompt' => 'Package context.',
'idempotency_key' => 'ledger-package-context-1',
'queued_at' => now(),
'completed_at' => now(),
]);
$ledger = app(AiUsageLedgerService::class)->recordDebitForRequest($request, 0.01234);
$this->assertSame('package_included', $ledger->package_context);
$this->assertSame('0.01234', $ledger->amount_usd);
$this->assertSame('runware', $ledger->metadata['provider'] ?? null);
}
public function test_it_records_addon_context_and_is_idempotent_per_request(): void
{
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$eventPackage = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(14),
]);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$style = AiStyle::query()->create([
'key' => 'ledger-style-addon',
'name' => 'Ledger Addon',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'prompt' => 'Addon context.',
'idempotency_key' => 'ledger-addon-context-1',
'queued_at' => now(),
'completed_at' => now(),
]);
$service = app(AiUsageLedgerService::class);
$first = $service->recordDebitForRequest($request, 0.01);
$second = $service->recordDebitForRequest($request, 0.99);
$this->assertSame($first->id, $second->id);
$this->assertSame('addon_unlock', $first->package_context);
$this->assertSame(1, $request->usageLedgers()->count());
}
}