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(), ]; } }