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'); $periodStart = now()->startOfMonth(); $periodEnd = now()->endOfMonth(); $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); $failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0)); $moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0); $usageQuery = AiUsageLedger::query() ->where('tenant_id', $event->tenant_id) ->where('event_id', $event->id) ->where('recorded_at', '>=', $periodStart) ->where('recorded_at', '<=', $periodEnd); $spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0); $debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count()); $providerRunQuery = AiProviderRun::query() ->whereHas('request', fn ($query) => $query->where('event_id', $event->id)) ->where('created_at', '>=', $periodStart) ->where('created_at', '<=', $periodEnd); $providerRunTotal = (int) (clone $providerRunQuery)->count(); $providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count(); $averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0)); $failureRate = $total > 0 ? ($failedTotal / $total) : 0.0; $moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0; $providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0; $failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35); $latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000)); $budgetDecision = $this->budgetGuard->evaluateForEvent($event); return response()->json([ 'data' => [ 'event_id' => $event->id, 'total' => $total, 'status_counts' => $statusCounts, 'safety_counts' => $safetyCounts, 'failed_total' => $failedTotal, 'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null, 'usage' => [ 'period_start' => $periodStart->toDateString(), 'period_end' => $periodEnd->toDateString(), 'debit_count' => $debitCount, 'spend_usd' => round($spendUsd, 5), ], 'observability' => [ 'failure_rate' => round($failureRate, 5), 'moderation_hit_rate' => round($moderationHitRate, 5), 'provider_runs_total' => $providerRunTotal, 'provider_runs_failed' => $providerRunFailed, 'provider_failure_rate' => round($providerFailureRate, 5), 'avg_provider_latency_ms' => $averageProviderLatencyMs, 'alerts' => [ 'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)), 'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs, ], ], 'budget' => $budgetDecision['budget'], ], ]); } 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 ); } $budgetDecision = $this->budgetGuard->evaluateForEvent($event); if (! $budgetDecision['allowed']) { return ApiError::response( $budgetDecision['reason_code'] ?? 'budget_hard_cap_reached', 'Budget limit reached', $budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.', Response::HTTP_FORBIDDEN, [ 'budget' => $budgetDecision['budget'], ] ); } 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; $scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user'; $abuseSignal = null; $safetyReasons = $safetyDecision->reasonCodes; if ($safetyDecision->blocked) { $abuseSignal = $this->abuseEscalation->recordPromptBlock( (int) $event->tenant_id, (int) $event->id, $scopeKey ); if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) { $safetyReasons[] = AiAbuseEscalationService::REASON_CODE; } } $metadata = (array) $request->input('metadata', []); if (is_array($abuseSignal)) { $metadata['abuse'] = $abuseSignal; } $metadata['budget'] = $budgetDecision['budget']; $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' => $safetyReasons, 'failure_code' => $safetyDecision->failureCode, 'failure_message' => $safetyDecision->failureMessage, 'queued_at' => now(), 'completed_at' => $safetyDecision->blocked ? now() : null, 'metadata' => $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, 'version' => $style->version, '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, 'url' => $this->resolveOutputUrl( $output->storage_disk, $output->storage_path, $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(), ]; } private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string { $resolvedStoragePath = $this->normalizeOptionalString($storagePath); if ($resolvedStoragePath !== null) { if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) { return $resolvedStoragePath; } $disk = $this->resolveStorageDisk($storageDisk); try { return Storage::disk($disk)->url($resolvedStoragePath); } catch (\Throwable $exception) { Log::debug('Falling back to raw AI output storage path', [ 'disk' => $disk, 'path' => $resolvedStoragePath, 'error' => $exception->getMessage(), ]); return '/'.ltrim($resolvedStoragePath, '/'); } } return $this->normalizeOptionalString($providerUrl); } private function resolveStorageDisk(?string $disk): string { $candidate = trim((string) ($disk ?: config('filesystems.default', 'public'))); if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) { return (string) config('filesystems.default', 'public'); } return $candidate; } }