From 36bed12ff94d3b54768a45e7cb8dd55b362d458a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 6 Feb 2026 20:01:58 +0100 Subject: [PATCH] feat: implement AI styling foundation and billing scope rework --- .../Resources/AiStyles/AiStyleResource.php | 154 ++++ .../AiStyles/Pages/ManageAiStyles.php | 26 + app/Filament/Resources/PackageResource.php | 1 + .../Pages/AiEditingSettingsPage.php | 177 +++++ .../Api/EventPublicAiEditController.php | 468 +++++++++++ .../Controllers/Api/EventPublicController.php | 18 + .../Api/Tenant/AiEditController.php | 488 ++++++++++++ .../Api/TenantBillingController.php | 57 +- .../Requests/Api/GuestAiEditStoreRequest.php | 26 + .../Requests/Tenant/AiEditIndexRequest.php | 22 + .../Requests/Tenant/AiEditStoreRequest.php | 27 + .../Tenant/BillingAddonHistoryRequest.php | 24 + .../Requests/Tenant/EventStoreRequest.php | 10 + app/Http/Resources/Tenant/EventResource.php | 15 + app/Jobs/PollAiEditRequest.php | 148 ++++ app/Jobs/ProcessAiEditRequest.php | 180 +++++ app/Models/AiEditOutput.php | 51 ++ app/Models/AiEditRequest.php | 103 +++ app/Models/AiEditingSetting.php | 63 ++ app/Models/AiProviderRun.php | 56 ++ app/Models/AiStyle.php | 44 ++ app/Models/AiUsageLedger.php | 60 ++ app/Models/Event.php | 5 + app/Models/Photo.php | 5 + app/Providers/AppServiceProvider.php | 40 + .../AiEditing/AiEditingRuntimeConfig.php | 65 ++ .../AiEditing/AiImageProviderManager.php | 18 + app/Services/AiEditing/AiProviderResult.php | 118 +++ .../AiEditing/AiStyleAccessService.php | 134 ++++ .../AiEditing/AiStylingEntitlementService.php | 209 +++++ .../AiEditing/AiUsageLedgerService.php | 67 ++ .../AiEditing/Contracts/AiImageProvider.php | 13 + .../AiEditing/EventAiEditingPolicyService.php | 90 +++ .../Providers/NullAiImageProvider.php | 26 + .../Providers/RunwareAiImageProvider.php | 287 +++++++ .../AiEditing/Safety/AiSafetyDecision.php | 39 + .../Safety/AiSafetyPolicyService.php | 100 +++ config/ai-editing.php | 48 ++ config/package-addons.php | 7 + config/services.php | 6 + ..._02_06_110955_create_ai_editing_tables.php | 146 ++++ ...41000_create_ai_editing_settings_table.php | 30 + database/seeders/PackageAddonSeeder.php | 16 + database/seeders/PackageSeeder.php | 2 +- public/lang/de/marketing.json | 1 + public/lang/en/marketing.json | 1 + resources/js/admin/api.ts | 308 +++++++- .../js/admin/i18n/locales/de/management.json | 68 +- .../js/admin/i18n/locales/en/management.json | 68 +- resources/js/admin/mobile/BillingPage.tsx | 422 +++++++++- .../js/admin/mobile/EventControlRoomPage.tsx | 414 +++++++++- .../mobile/__tests__/BillingPage.test.tsx | 155 +++- .../js/admin/mobile/lib/packageSummary.ts | 4 + .../__tests__/AiMagicEditSheet.test.tsx | 229 ++++++ .../guest-v2/__tests__/GalleryScreen.test.tsx | 43 +- .../__tests__/PhotoLightboxScreen.test.tsx | 20 +- .../guest-v2/components/AiMagicEditSheet.tsx | 642 +++++++++++++++ resources/js/guest-v2/lib/featureFlags.ts | 1 + .../js/guest-v2/screens/GalleryScreen.tsx | 33 + .../guest-v2/screens/PhotoLightboxScreen.tsx | 39 +- .../services/__tests__/aiEditsApi.test.ts | 81 ++ resources/js/guest-v2/services/aiEditsApi.ts | 142 ++++ .../js/shared/guest/services/eventApi.ts | 7 + resources/lang/de/marketing.json | 1 + resources/lang/de/marketing.php | 1 + resources/lang/en/marketing.json | 1 + resources/lang/en/marketing.php | 1 + .../pages/ai-editing-settings-page.blade.php | 19 + routes/api.php | 32 + tests/Feature/Ai/AiEditingDataModelTest.php | 147 ++++ .../Api/Event/EventAiEditControllerTest.php | 720 +++++++++++++++++ .../Api/Event/EventPublicCapabilitiesTest.php | 99 +++ .../Api/Tenant/BillingAddonHistoryTest.php | 126 +++ .../Api/Tenant/EventAddonsSummaryTest.php | 38 + .../Api/Tenant/TenantAiEditControllerTest.php | 731 ++++++++++++++++++ .../Feature/Jobs/ProcessAiEditRequestTest.php | 191 +++++ .../Unit/PackageResourceFeatureLabelTest.php | 16 + .../Services/AiStyleAccessServiceTest.php | 158 ++++ .../AiStylingEntitlementServiceTest.php | 221 ++++++ .../Services/AiUsageLedgerServiceTest.php | 124 +++ 80 files changed, 8944 insertions(+), 49 deletions(-) create mode 100644 app/Filament/Resources/AiStyles/AiStyleResource.php create mode 100644 app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php create mode 100644 app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php create mode 100644 app/Http/Controllers/Api/EventPublicAiEditController.php create mode 100644 app/Http/Controllers/Api/Tenant/AiEditController.php create mode 100644 app/Http/Requests/Api/GuestAiEditStoreRequest.php create mode 100644 app/Http/Requests/Tenant/AiEditIndexRequest.php create mode 100644 app/Http/Requests/Tenant/AiEditStoreRequest.php create mode 100644 app/Http/Requests/Tenant/BillingAddonHistoryRequest.php create mode 100644 app/Jobs/PollAiEditRequest.php create mode 100644 app/Jobs/ProcessAiEditRequest.php create mode 100644 app/Models/AiEditOutput.php create mode 100644 app/Models/AiEditRequest.php create mode 100644 app/Models/AiEditingSetting.php create mode 100644 app/Models/AiProviderRun.php create mode 100644 app/Models/AiStyle.php create mode 100644 app/Models/AiUsageLedger.php create mode 100644 app/Services/AiEditing/AiEditingRuntimeConfig.php create mode 100644 app/Services/AiEditing/AiImageProviderManager.php create mode 100644 app/Services/AiEditing/AiProviderResult.php create mode 100644 app/Services/AiEditing/AiStyleAccessService.php create mode 100644 app/Services/AiEditing/AiStylingEntitlementService.php create mode 100644 app/Services/AiEditing/AiUsageLedgerService.php create mode 100644 app/Services/AiEditing/Contracts/AiImageProvider.php create mode 100644 app/Services/AiEditing/EventAiEditingPolicyService.php create mode 100644 app/Services/AiEditing/Providers/NullAiImageProvider.php create mode 100644 app/Services/AiEditing/Providers/RunwareAiImageProvider.php create mode 100644 app/Services/AiEditing/Safety/AiSafetyDecision.php create mode 100644 app/Services/AiEditing/Safety/AiSafetyPolicyService.php create mode 100644 config/ai-editing.php create mode 100644 database/migrations/2026_02_06_110955_create_ai_editing_tables.php create mode 100644 database/migrations/2026_02_06_141000_create_ai_editing_settings_table.php create mode 100644 resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx create mode 100644 resources/js/guest-v2/components/AiMagicEditSheet.tsx create mode 100644 resources/js/guest-v2/lib/featureFlags.ts create mode 100644 resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts create mode 100644 resources/js/guest-v2/services/aiEditsApi.ts create mode 100644 resources/views/filament/super-admin/pages/ai-editing-settings-page.blade.php create mode 100644 tests/Feature/Ai/AiEditingDataModelTest.php create mode 100644 tests/Feature/Api/Event/EventAiEditControllerTest.php create mode 100644 tests/Feature/Api/Event/EventPublicCapabilitiesTest.php create mode 100644 tests/Feature/Api/Tenant/TenantAiEditControllerTest.php create mode 100644 tests/Feature/Jobs/ProcessAiEditRequestTest.php create mode 100644 tests/Unit/PackageResourceFeatureLabelTest.php create mode 100644 tests/Unit/Services/AiStyleAccessServiceTest.php create mode 100644 tests/Unit/Services/AiStylingEntitlementServiceTest.php create mode 100644 tests/Unit/Services/AiUsageLedgerServiceTest.php diff --git a/app/Filament/Resources/AiStyles/AiStyleResource.php b/app/Filament/Resources/AiStyles/AiStyleResource.php new file mode 100644 index 00000000..b7cd7d30 --- /dev/null +++ b/app/Filament/Resources/AiStyles/AiStyleResource.php @@ -0,0 +1,154 @@ +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('/'), + ]; + } +} diff --git a/app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php b/app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php new file mode 100644 index 00000000..60f579d4 --- /dev/null +++ b/app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php @@ -0,0 +1,26 @@ +after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( + 'created', + $record, + SuperAdminAuditLogger::fieldsMetadata(array_keys($data)), + static::class + )), + ]; + } +} diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index 4d498592..e1a2e1f0 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -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', diff --git a/app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php b/app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php new file mode 100644 index 00000000..cc9f7444 --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php @@ -0,0 +1,177 @@ + + */ + 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; + } +} diff --git a/app/Http/Controllers/Api/EventPublicAiEditController.php b/app/Http/Controllers/Api/EventPublicAiEditController.php new file mode 100644 index 00000000..d292ede0 --- /dev/null +++ b/app/Http/Controllers/Api/EventPublicAiEditController.php @@ -0,0 +1,468 @@ +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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 3194fbe2..885e26f8 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -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'); } diff --git a/app/Http/Controllers/Api/Tenant/AiEditController.php b/app/Http/Controllers/Api/Tenant/AiEditController.php new file mode 100644 index 00000000..7451d32c --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/AiEditController.php @@ -0,0 +1,488 @@ +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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php index e2745c16..83aa55a5 100644 --- a/app/Http/Controllers/Api/TenantBillingController.php +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -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, + ], ], ]); } diff --git a/app/Http/Requests/Api/GuestAiEditStoreRequest.php b/app/Http/Requests/Api/GuestAiEditStoreRequest.php new file mode 100644 index 00000000..d591b00a --- /dev/null +++ b/app/Http/Requests/Api/GuestAiEditStoreRequest.php @@ -0,0 +1,26 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/AiEditIndexRequest.php b/app/Http/Requests/Tenant/AiEditIndexRequest.php new file mode 100644 index 00000000..ef152512 --- /dev/null +++ b/app/Http/Requests/Tenant/AiEditIndexRequest.php @@ -0,0 +1,22 @@ + ['nullable', 'string', 'max:30'], + 'safety_state' => ['nullable', 'string', 'max:30'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/AiEditStoreRequest.php b/app/Http/Requests/Tenant/AiEditStoreRequest.php new file mode 100644 index 00000000..4f3985bc --- /dev/null +++ b/app/Http/Requests/Tenant/AiEditStoreRequest.php @@ -0,0 +1,27 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/BillingAddonHistoryRequest.php b/app/Http/Requests/Tenant/BillingAddonHistoryRequest.php new file mode 100644 index 00000000..e343bdd1 --- /dev/null +++ b/app/Http/Requests/Tenant/BillingAddonHistoryRequest.php @@ -0,0 +1,24 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index d0410ae5..2196517f 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -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'], diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index 8d14484f..1ecf4d93 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -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, ]; } diff --git a/app/Jobs/PollAiEditRequest.php b/app/Jobs/PollAiEditRequest.php new file mode 100644 index 00000000..6e946a36 --- /dev/null +++ b/app/Jobs/PollAiEditRequest.php @@ -0,0 +1,148 @@ + + */ + 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(); + } +} diff --git a/app/Jobs/ProcessAiEditRequest.php b/app/Jobs/ProcessAiEditRequest.php new file mode 100644 index 00000000..4e8ac17b --- /dev/null +++ b/app/Jobs/ProcessAiEditRequest.php @@ -0,0 +1,180 @@ + + */ + 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(); + } +} diff --git a/app/Models/AiEditOutput.php b/app/Models/AiEditOutput.php new file mode 100644 index 00000000..f05a3981 --- /dev/null +++ b/app/Models/AiEditOutput.php @@ -0,0 +1,51 @@ + '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); + } +} diff --git a/app/Models/AiEditRequest.php b/app/Models/AiEditRequest.php new file mode 100644 index 00000000..ce9719a4 --- /dev/null +++ b/app/Models/AiEditRequest.php @@ -0,0 +1,103 @@ + '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'); + } +} diff --git a/app/Models/AiEditingSetting.php b/app/Models/AiEditingSetting.php new file mode 100644 index 00000000..6394f9e2 --- /dev/null +++ b/app/Models/AiEditingSetting.php @@ -0,0 +1,63 @@ + '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 + */ + 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'); + } +} diff --git a/app/Models/AiProviderRun.php b/app/Models/AiProviderRun.php new file mode 100644 index 00000000..8c2a8207 --- /dev/null +++ b/app/Models/AiProviderRun.php @@ -0,0 +1,56 @@ + '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'); + } +} diff --git a/app/Models/AiStyle.php b/app/Models/AiStyle.php new file mode 100644 index 00000000..f19dedad --- /dev/null +++ b/app/Models/AiStyle.php @@ -0,0 +1,44 @@ + 'boolean', + 'is_premium' => 'boolean', + 'is_active' => 'boolean', + 'sort' => 'integer', + 'metadata' => 'array', + ]; + } + + public function editRequests(): HasMany + { + return $this->hasMany(AiEditRequest::class, 'style_id'); + } +} diff --git a/app/Models/AiUsageLedger.php b/app/Models/AiUsageLedger.php new file mode 100644 index 00000000..339f3ea5 --- /dev/null +++ b/app/Models/AiUsageLedger.php @@ -0,0 +1,60 @@ + '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'); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 1494c5a3..546a6b24 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -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( diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 0fa28456..205f70d8 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -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'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b08ff1dc..0a26195f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 : ''); diff --git a/app/Services/AiEditing/AiEditingRuntimeConfig.php b/app/Services/AiEditing/AiEditingRuntimeConfig.php new file mode 100644 index 00000000..e265c3c9 --- /dev/null +++ b/app/Services/AiEditing/AiEditingRuntimeConfig.php @@ -0,0 +1,65 @@ +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 + */ + 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; + } +} diff --git a/app/Services/AiEditing/AiImageProviderManager.php b/app/Services/AiEditing/AiImageProviderManager.php new file mode 100644 index 00000000..f79ec0eb --- /dev/null +++ b/app/Services/AiEditing/AiImageProviderManager.php @@ -0,0 +1,18 @@ + app(RunwareAiImageProvider::class), + default => app(NullAiImageProvider::class), + }; + } +} diff --git a/app/Services/AiEditing/AiProviderResult.php b/app/Services/AiEditing/AiProviderResult.php new file mode 100644 index 00000000..42d0fcb8 --- /dev/null +++ b/app/Services/AiEditing/AiProviderResult.php @@ -0,0 +1,118 @@ +> $outputs + * @param array $requestPayload + * @param array $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> $outputs + * @param array $requestPayload + * @param array $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 $requestPayload + * @param array $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 $requestPayload + * @param array $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 $requestPayload + * @param array $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, + ); + } +} diff --git a/app/Services/AiEditing/AiStyleAccessService.php b/app/Services/AiEditing/AiStyleAccessService.php new file mode 100644 index 00000000..f5181644 --- /dev/null +++ b/app/Services/AiEditing/AiStyleAccessService.php @@ -0,0 +1,134 @@ +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 $styles + * @return Collection + */ + public function filterStylesForEvent(Event $event, Collection $styles): Collection + { + return $styles + ->filter(fn (AiStyle $style): bool => $this->canUseStyle($event, $style)) + ->values(); + } + + /** + * @return array + */ + 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 + */ + private function requiredPackageFeatures(AiStyle $style): array + { + return $this->normalizeStringList( + Arr::get($style->metadata ?? [], 'entitlements.required_package_features', []) + ); + } + + /** + * @return array + */ + 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 + */ + 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 + */ + private function normalizeStringList(array $values): array + { + return array_values(array_unique(array_filter(array_map( + static fn (mixed $value): string => trim((string) $value), + $values + )))); + } +} diff --git a/app/Services/AiEditing/AiStylingEntitlementService.php b/app/Services/AiEditing/AiStylingEntitlementService.php new file mode 100644 index 00000000..1eb71b4a --- /dev/null +++ b/app/Services/AiEditing/AiStylingEntitlementService.php @@ -0,0 +1,209 @@ + + */ + 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 + * } + */ + 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 $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 + */ + 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)) + ))); + } +} diff --git a/app/Services/AiEditing/AiUsageLedgerService.php b/app/Services/AiEditing/AiUsageLedgerService.php new file mode 100644 index 00000000..1049b458 --- /dev/null +++ b/app/Services/AiEditing/AiUsageLedgerService.php @@ -0,0 +1,67 @@ + $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), + ]); + }); + } +} diff --git a/app/Services/AiEditing/Contracts/AiImageProvider.php b/app/Services/AiEditing/Contracts/AiImageProvider.php new file mode 100644 index 00000000..00ac437e --- /dev/null +++ b/app/Services/AiEditing/Contracts/AiImageProvider.php @@ -0,0 +1,13 @@ +, + * 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 $allowedStyleKeys */ + $allowedStyleKeys = $policy['allowed_style_keys']; + if ($allowedStyleKeys === []) { + return true; + } + + return in_array($style->key, $allowedStyleKeys, true); + } + + /** + * @param Collection $styles + * @return Collection + */ + public function filterStyles(Event $event, Collection $styles): Collection + { + $policy = $this->resolve($event); + + /** @var array $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(); + } +} diff --git a/app/Services/AiEditing/Providers/NullAiImageProvider.php b/app/Services/AiEditing/Providers/NullAiImageProvider.php new file mode 100644 index 00000000..6eec277a --- /dev/null +++ b/app/Services/AiEditing/Providers/NullAiImageProvider.php @@ -0,0 +1,26 @@ +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) + ); + } +} diff --git a/app/Services/AiEditing/Providers/RunwareAiImageProvider.php b/app/Services/AiEditing/Providers/RunwareAiImageProvider.php new file mode 100644 index 00000000..7cb98430 --- /dev/null +++ b/app/Services/AiEditing/Providers/RunwareAiImageProvider.php @@ -0,0 +1,287 @@ +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; + } +} diff --git a/app/Services/AiEditing/Safety/AiSafetyDecision.php b/app/Services/AiEditing/Safety/AiSafetyDecision.php new file mode 100644 index 00000000..f59b5bf1 --- /dev/null +++ b/app/Services/AiEditing/Safety/AiSafetyDecision.php @@ -0,0 +1,39 @@ + $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 $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, + ); + } +} diff --git a/app/Services/AiEditing/Safety/AiSafetyPolicyService.php b/app/Services/AiEditing/Safety/AiSafetyPolicyService.php new file mode 100644 index 00000000..2db7b559 --- /dev/null +++ b/app/Services/AiEditing/Safety/AiSafetyPolicyService.php @@ -0,0 +1,100 @@ + 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; + } +} diff --git a/config/ai-editing.php b/config/ai-editing.php new file mode 100644 index 00000000..0c9bf273 --- /dev/null +++ b/config/ai-editing.php @@ -0,0 +1,48 @@ + 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'), + ], + ], +]; diff --git a/config/package-addons.php b/config/package-addons.php index cbd0e1bc..40d95a84 100644 --- a/config/package-addons.php +++ b/config/package-addons.php @@ -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' => [], + ], ]; diff --git a/config/services.php b/config/services.php index f4bbba44..74fd6df6 100644 --- a/config/services.php +++ b/config/services.php @@ -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), + ], + ]; diff --git a/database/migrations/2026_02_06_110955_create_ai_editing_tables.php b/database/migrations/2026_02_06_110955_create_ai_editing_tables.php new file mode 100644 index 00000000..79ec5a6a --- /dev/null +++ b/database/migrations/2026_02_06_110955_create_ai_editing_tables.php @@ -0,0 +1,146 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_06_141000_create_ai_editing_settings_table.php b/database/migrations/2026_02_06_141000_create_ai_editing_settings_table.php new file mode 100644 index 00000000..27f9dd0d --- /dev/null +++ b/database/migrations/2026_02_06_141000_create_ai_editing_settings_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/seeders/PackageAddonSeeder.php b/database/seeders/PackageAddonSeeder.php index 18e57614..649dde6b 100644 --- a/database/seeders/PackageAddonSeeder.php +++ b/database/seeders/PackageAddonSeeder.php @@ -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) { diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index 87b69703..c41d4fd4 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -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' diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index d085614c..7077f945 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -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", diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 8d7ec4ae..a59715fa 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -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", diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 9b31f3d3..3090f653 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -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; +}; + +export type AiEditUsageSummary = { + event_id: number; + total: number; + failed_total: number; + status_counts: Record; + safety_counts: Record; + 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 & { 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) + .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) ?? {}; + 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), 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) + : {}, + }; +} + function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null { if (!payload) { return null; @@ -1759,6 +1931,63 @@ export async function getEvent(slug: string): Promise { 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 }>( + 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 { + const response = await authorizedFetch(`${eventEndpoint(slug)}/ai-edits/summary`); + const payload = await jsonOrThrow<{ data?: Record }>(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) : {}; + const safetyCounts = row.safety_counts && typeof row.safety_counts === 'object' ? (row.safety_counts as Record) : {}; + + 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; current_page?: number; last_page?: number; per_page?: number; total?: number }>( + const payload = await jsonOrThrow<{ + data?: JsonValue[]; + meta?: Partial & { + 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) + : 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 }; } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index bfc1e416..ba93c811 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -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", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index dc4faee3..3132cc7f 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -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", diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index bdd5c7e7..bd9b2511 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -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([]); const [activePackage, setActivePackage] = React.useState(null); const [transactions, setTransactions] = React.useState([]); + const [transactionsMeta, setTransactionsMeta] = React.useState(() => + createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE) + ); + const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false); const [addons, setAddons] = React.useState([]); + const [addonsMeta, setAddonsMeta] = React.useState(() => + createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE) + ); + const [addonsLoadingMore, setAddonsLoadingMore] = React.useState(false); + const [scopeAddons, setScopeAddons] = React.useState([]); + const [scopeAddonsMeta, setScopeAddonsMeta] = React.useState(() => + createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE) + ); + const [scopeAddonsLoadingMore, setScopeAddonsLoadingMore] = React.useState(false); + const [scopeEvent, setScopeEvent] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [showPackageHistory, setShowPackageHistory] = React.useState(false); @@ -63,6 +94,7 @@ export default function MobileBillingPage() { const [checkoutStatusReason, setCheckoutStatusReason] = React.useState(null); const [checkoutActionUrl, setCheckoutActionUrl] = React.useState(null); const lastCheckoutStatusRef = React.useRef(null); + const mismatchTrackingRef = React.useRef(null); const packagesRef = React.useRef(null); const invoicesRef = React.useRef(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(() => { + 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() { ) : null} + + + + + {t('billing.sections.currentEvent.title', 'Current event')} + + + + {t( + 'billing.sections.currentEvent.hint', + 'This section shows what is active for your currently selected event.' + )} + + {loading ? ( + + {t('common.loading', 'Lädt...')} + + ) : !scopeEvent ? ( + + {t('billing.sections.currentEvent.empty', 'Select an event to view event-specific packages and add-ons.')} + + ) : ( + + + + {t('billing.sections.currentEvent.eventLabel', 'Selected event')} + + {scopedEventName ? ( + scopedEventPath ? ( + navigate(scopedEventPath)}> + + {scopedEventName} + + + ) : ( + + {scopedEventName} + + ) + ) : null} + + {scopedEventPackage ? ( + + + + {scopedEventPackage.name ?? t('mobileBilling.packageFallback', 'Package')} + + + {t('billing.sections.currentEvent.packageActive', 'Active for this event')} + + + {scopedEventPackage.expires_at ? ( + + {t('billing.sections.currentEvent.packageExpires', 'Gallery active until {{date}}', { + date: formatDate(scopedEventPackage.expires_at), + })} + + ) : null} + + ) : ( + + + {t('billing.sections.currentEvent.noPackage', 'No package is currently assigned to this event.')} + + + )} + + + + {t('billing.sections.currentEvent.addonsLabel', 'Add-ons for this event')} + + {scopeAddons.length > 0 ? ( + + {scopeAddons.map((addon) => ( + + ))} + {scopeAddonsMeta.current_page < scopeAddonsMeta.last_page ? ( + void loadMoreScopeAddons()} + tone="ghost" + /> + ) : null} + + ) : scopedEventAddons.length > 0 ? ( + + {scopedEventAddons.slice(0, 6).map((addon) => ( + + ))} + + ) : ( + + {t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')} + + )} + + + )} + + - {t('billing.sections.packages.title', 'Packages')} + {t('billing.sections.packages.title', 'Package history (all events)')} - {t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')} + {t( + 'billing.sections.packages.hint', + 'All purchased packages across all events.' + )} {loading ? ( @@ -406,7 +716,7 @@ export default function MobileBillingPage() { {activePackage ? ( navigate(shopLink)} /> @@ -470,7 +780,7 @@ export default function MobileBillingPage() { ) : ( - {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() { ); })} + {transactionsMeta.current_page < transactionsMeta.last_page ? ( + void loadMoreTransactions()} + tone="ghost" + /> + ) : null} )} @@ -527,11 +848,14 @@ export default function MobileBillingPage() { - {t('billing.sections.addOns.title', 'Add-ons')} + {t('billing.sections.addOns.title', 'Add-on purchase history')} - {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.' + )} {loading ? ( @@ -543,9 +867,20 @@ export default function MobileBillingPage() { ) : ( - {addons.slice(0, 8).map((addon) => ( - + {addons.map((addon) => ( + ))} + {addonsMeta.current_page < addonsMeta.last_page ? ( + void loadMoreAddonHistory()} + tone="ghost" + /> + ) : null} )} @@ -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 ? ( @@ -884,7 +1233,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) { {status.text} - {eventName ? ( + {!hideEventLink && eventName ? ( eventPath ? ( navigate(eventPath)}> @@ -909,9 +1258,56 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) { {formatDate(addon.purchased_at)} + {purchasedForDifferentEvent ? ( + + {t('billing.sections.addOns.otherEventNotice', 'Purchased for another event')} + + ) : null} ); } + +function EventAddonRow({ addon }: { addon: EventAddonSummary }) { + const { t } = useTranslation('management'); + const { border, textStrong, text, muted } = useAdminTheme(); + + const labels: Record = { + 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 ( + + + + {addon.label ?? addon.key} + + {status.text} + + + {addon.extra_photos ? ( + {t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })} + ) : null} + {addon.extra_guests ? ( + {t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })} + ) : null} + {addon.extra_gallery_days ? ( + {t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })} + ) : null} + + + {formatDate(addon.purchased_at)} + + + {t('billing.sections.currentEvent.eventAddonSource', 'Source: event package')} + + + ); +} + function formatDate(value: string | null | undefined): string { if (!value) return '—'; const date = new Date(value); diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index 67e2a115..392e6843 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -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; function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => 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([]); const [busyScope, setBusyScope] = React.useState(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([]); @@ -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([]); + const [aiUsageSummary, setAiUsageSummary] = React.useState(null); + const [aiSettingsDraft, setAiSettingsDraft] = React.useState(DEFAULT_AI_SETTINGS); + const [initialAiSettingsDraft, setInitialAiSettingsDraft] = React.useState(DEFAULT_AI_SETTINGS); + const [aiSettingsLoading, setAiSettingsLoading] = React.useState(false); + const [aiSettingsSaving, setAiSettingsSaving] = React.useState(false); + const [aiSettingsError, setAiSettingsError] = React.useState(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(() => 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(); 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() { + {!moderationLoading ? ( + aiStylingEntitled ? ( + + + + + + {t('controlRoom.aiSettings.title', 'AI Styling Controls')} + + + loadAiSettings()} + /> + + + {t( + 'controlRoom.aiSettings.subtitle', + 'Enable AI edits per event, configure allowed presets, and monitor usage/failures.' + )} + + + {aiSettingsError ? ( + + {aiSettingsError} + + ) : null} + + + + setAiSettingsDraft((previous) => ({ ...previous, enabled: Boolean(checked) })) + } + aria-label={t('controlRoom.aiSettings.enabled.label', 'Enable AI edits for this event')} + > + + + + + + + setAiSettingsDraft((previous) => ({ ...previous, allow_custom_prompt: Boolean(checked) })) + } + aria-label={t('controlRoom.aiSettings.customPrompt.label', 'Allow custom prompts')} + > + + + + + + + setAiSettingsDraft((previous) => ({ ...previous, policy_message: event.target.value })) + } + /> + + + + {aiSettingsLoading ? ( + + ) : aiStyles.length === 0 ? ( + + {t('controlRoom.aiSettings.styles.empty', 'No active AI styles found.')} + + ) : ( + + + {aiStyles.map((style) => { + const selected = aiSettingsDraft.allowed_style_keys.includes(style.key); + return ( + 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, + }} + > + + {style.name} + + + ); + })} + + + setAiSettingsDraft((previous) => ({ ...previous, allowed_style_keys: [] })) + } + /> + + )} + + + {aiUsageSummary ? ( + + + + {t('controlRoom.aiSettings.usage.title', 'Usage overview')} + + + + {t('controlRoom.aiSettings.usage.total', 'Total')} + {aiUsageSummary.total} + + + {t('controlRoom.aiSettings.usage.succeeded', 'Succeeded')} + + {aiUsageSummary.status_counts.succeeded ?? 0} + + + + {t('controlRoom.aiSettings.usage.failed', 'Failed')} + {aiUsageSummary.failed_total} + + + {aiUsageSummary.last_requested_at ? ( + + {t('controlRoom.aiSettings.usage.lastRequest', 'Last request: {{date}}', { + date: new Date(aiUsageSummary.last_requested_at).toLocaleString(), + })} + + ) : null} + + + ) : null} + + + saveAiSettings()} + /> + + + ) : null + ) : null} + {!moderationLoading ? ( ({ 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(); + + 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(); + + 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(); const receiptLink = await screen.findByText('Beleg'); diff --git a/resources/js/admin/mobile/lib/packageSummary.ts b/resources/js/admin/mobile/lib/packageSummary.ts index fcd530ca..a6f1b40a 100644 --- a/resources/js/admin/mobile/lib/packageSummary.ts +++ b/resources/js/admin/mobile/lib/packageSummary.ts @@ -24,6 +24,10 @@ const FEATURE_LABELS: Record = { 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', diff --git a/resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx b/resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx new file mode 100644 index 00000000..89a85834 --- /dev/null +++ b/resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx @@ -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: () => copy, + Download: () => download, + Loader2: () => loader, + MessageSquare: () => message, + RefreshCcw: () => refresh, + Share2: () => share, + Sparkles: () => sparkles, + Wand2: () => wand, + X: () => x, +})); + +vi.mock('@tamagui/sheet', () => { + const Sheet = ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ?
{children}
: null); + + Sheet.Overlay = ({ children }: { children?: React.ReactNode }) =>
{children}
; + Sheet.Frame = ({ children }: { children?: React.ReactNode }) =>
{children}
; + Sheet.Handle = ({ children }: { children?: React.ReactNode }) =>
{children}
; + + return { Sheet }; +}); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/button', () => ({ + Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => ( + + ), +})); + +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( + + ); + + 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( + + ); + + 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( + + ); + + 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); + }); +}); diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx index 7c832f48..f3b2ca3f 100644 --- a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx @@ -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(); + + 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(); + + await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled()); + await waitFor(() => + expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument() + ); + }); }); diff --git a/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx b/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx index 0dc17061..41655c7a 100644 --- a/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx @@ -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: () =>
ShareSheet
, })); +vi.mock('../components/AiMagicEditSheet', () => ({ + default: () =>
AiMagicEditSheet
, +})); + 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(); 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(); + + expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument(); }); }); diff --git a/resources/js/guest-v2/components/AiMagicEditSheet.tsx b/resources/js/guest-v2/components/AiMagicEditSheet.tsx new file mode 100644 index 00000000..a65c5a01 --- /dev/null +++ b/resources/js/guest-v2/components/AiMagicEditSheet.tsx @@ -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([]); + const [stylesLoading, setStylesLoading] = React.useState(false); + const [stylesError, setStylesError] = React.useState(null); + const [selectedStyleKey, setSelectedStyleKey] = React.useState(null); + const [request, setRequest] = React.useState(null); + const [requestError, setRequestError] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + const [isOnline, setIsOnline] = React.useState(() => { + 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 = ( + + + + + + + {t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')} + + + {t('galleryPage.lightbox.aiMagicEditSubtitle', 'Choose a style and generate an AI version.')} + + + + + + + {stylesLoading ? ( + + + + {t('galleryPage.lightbox.aiMagicEditLoadingStyles', 'Loading styles...')} + + + ) : null} + + {stylesError ? ( + + {stylesError} + + + + + ) : null} + + {!stylesLoading && !stylesError && !request ? ( + + + + {t('galleryPage.lightbox.aiMagicEditSelectStyle', 'Select style')} + + + {styles.map((style) => { + const selected = style.key === selectedStyleKey; + + return ( + + ); + })} + + {selectedStyle?.description ? ( + + {selectedStyle.description} + + ) : null} + + + {originalImageUrl ? ( + + + {t('galleryPage.lightbox.aiMagicEditOriginalPreview', 'Original photo')} + + {t('galleryPage.lightbox.aiMagicEditOriginalAlt', + + ) : null} + + + + ) : null} + + {request ? ( + + + + {request.style?.name ?? t('galleryPage.lightbox.aiMagicEditResultTitle', 'Result')} + + + {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.')} + + + + {isProcessing ? ( + + + + {t('galleryPage.lightbox.aiMagicEditProcessingHint', 'This can take a few seconds.')} + + + ) : null} + {isProcessing && !isOnline ? ( + + {t( + 'galleryPage.lightbox.aiMagicEditOfflineHint', + 'You are offline. Status updates resume automatically when connection is back.' + )} + + ) : null} + + {isDone && originalImageUrl ? ( + + + + {t('galleryPage.lightbox.aiMagicEditOriginalLabel', 'Original')} + + {t('galleryPage.lightbox.aiMagicEditOriginalAlt', + + + + {t('galleryPage.lightbox.aiMagicEditGeneratedLabel', 'AI result')} + + {t('galleryPage.lightbox.aiMagicEditGeneratedAlt', + + + ) : null} + + {requestError ? ( + {requestError} + ) : null} + + {(request.status === 'failed' || request.status === 'blocked' || request.status === 'canceled') && request.failure_message ? ( + {request.failure_message} + ) : null} + + + + {isDone ? ( + + ) : null} + + {isDone ? ( + + + + + + + ) : null} + + ) : null} + + ); + + return ( + + + + + + {content} + + + + ); +} diff --git a/resources/js/guest-v2/lib/featureFlags.ts b/resources/js/guest-v2/lib/featureFlags.ts new file mode 100644 index 00000000..aab355fd --- /dev/null +++ b/resources/js/guest-v2/lib/featureFlags.ts @@ -0,0 +1 @@ +export const GUEST_AI_MAGIC_EDITS_ENABLED = false; diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index 8b289343..223bc94b 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -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() { ) : null} + {lightboxPhoto && hasAiStylingAccess ? ( + + ) : null} {lightboxPhoto && canDelete ? ( + {hasAiStylingAccess ? ( + + ) : null}
@@ -667,6 +695,15 @@ export default function PhotoLightboxScreen() { onCopyLink={() => copyLink(shareSheet.url)} variant="inline" /> + {hasAiStylingAccess ? ( + + ) : null} diff --git a/resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts b/resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts new file mode 100644 index 00000000..6a45d164 --- /dev/null +++ b/resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts @@ -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.'); + }); +}); diff --git a/resources/js/guest-v2/services/aiEditsApi.ts b/resources/js/guest-v2/services/aiEditsApi.ts new file mode 100644 index 00000000..a175d1fc --- /dev/null +++ b/resources/js/guest-v2/services/aiEditsApi.ts @@ -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; +}; + +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 { + return { + 'X-Device-Id': getDeviceId(), + }; +} + +export async function fetchGuestAiStyles(eventToken: string): Promise { + const response = await fetchJson( + `/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; + } +): Promise { + const response = await fetchJson( + `/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; +} diff --git a/resources/js/shared/guest/services/eventApi.ts b/resources/js/shared/guest/services/eventApi.ts index 8d2aef94..59c39a5d 100644 --- a/resources/js/shared/guest/services/eventApi.ts +++ b/resources/js/shared/guest/services/eventApi.ts @@ -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 { diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index 69e6fd27..3636b7fd 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -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", diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index cf594b95..5817d219 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -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', diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index f19d8cca..0236f6a2 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -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", diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index 2100b7e4..3335b43d 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -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', diff --git a/resources/views/filament/super-admin/pages/ai-editing-settings-page.blade.php b/resources/views/filament/super-admin/pages/ai-editing-settings-page.blade.php new file mode 100644 index 00000000..37c02ae0 --- /dev/null +++ b/resources/views/filament/super-admin/pages/ai-editing-settings-page.blade.php @@ -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 + + + +
+ {{ $this->form }} + + Save settings + +
+
+
+ diff --git a/routes/api.php b/routes/api.php index 7d4018a4..d1baadab 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ 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'); diff --git a/tests/Feature/Ai/AiEditingDataModelTest.php b/tests/Feature/Ai/AiEditingDataModelTest.php new file mode 100644 index 00000000..6181a09c --- /dev/null +++ b/tests/Feature/Ai/AiEditingDataModelTest.php @@ -0,0 +1,147 @@ +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); + } +} diff --git a/tests/Feature/Api/Event/EventAiEditControllerTest.php b/tests/Feature/Api/Event/EventAiEditControllerTest.php new file mode 100644 index 00000000..a08fac52 --- /dev/null +++ b/tests/Feature/Api/Event/EventAiEditControllerTest.php @@ -0,0 +1,720 @@ +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]); + } +} diff --git a/tests/Feature/Api/Event/EventPublicCapabilitiesTest.php b/tests/Feature/Api/Event/EventPublicCapabilitiesTest.php new file mode 100644 index 00000000..c59e2d72 --- /dev/null +++ b/tests/Feature/Api/Event/EventPublicCapabilitiesTest.php @@ -0,0 +1,99 @@ +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 $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), + ]); + } +} diff --git a/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php b/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php index 3767d63d..b7265508 100644 --- a/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php +++ b/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php @@ -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.'); + } } diff --git a/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php b/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php index 52421821..1160ee69 100644 --- a/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php +++ b/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php @@ -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'); } } diff --git a/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php b/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php new file mode 100644 index 00000000..bfae5b8f --- /dev/null +++ b/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php @@ -0,0 +1,731 @@ +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]); + } +} diff --git a/tests/Feature/Jobs/ProcessAiEditRequestTest.php b/tests/Feature/Jobs/ProcessAiEditRequestTest.php new file mode 100644 index 00000000..96449fe5 --- /dev/null +++ b/tests/Feature/Jobs/ProcessAiEditRequestTest.php @@ -0,0 +1,191 @@ +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()); + } +} diff --git a/tests/Unit/PackageResourceFeatureLabelTest.php b/tests/Unit/PackageResourceFeatureLabelTest.php new file mode 100644 index 00000000..b21dab81 --- /dev/null +++ b/tests/Unit/PackageResourceFeatureLabelTest.php @@ -0,0 +1,16 @@ +assertSame('AI-Styling, Eigenes Branding', $formatted); + } +} diff --git a/tests/Unit/Services/AiStyleAccessServiceTest.php b/tests/Unit/Services/AiStyleAccessServiceTest.php new file mode 100644 index 00000000..bd7fa705 --- /dev/null +++ b/tests/Unit/Services/AiStyleAccessServiceTest.php @@ -0,0 +1,158 @@ +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 $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(), + ]); + } +} diff --git a/tests/Unit/Services/AiStylingEntitlementServiceTest.php b/tests/Unit/Services/AiStylingEntitlementServiceTest.php new file mode 100644 index 00000000..1fbfd458 --- /dev/null +++ b/tests/Unit/Services/AiStylingEntitlementServiceTest.php @@ -0,0 +1,221 @@ +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']); + } +} diff --git a/tests/Unit/Services/AiUsageLedgerServiceTest.php b/tests/Unit/Services/AiUsageLedgerServiceTest.php new file mode 100644 index 00000000..e3b0c928 --- /dev/null +++ b/tests/Unit/Services/AiUsageLedgerServiceTest.php @@ -0,0 +1,124 @@ +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()); + } +}