feat: implement AI styling foundation and billing scope rework
This commit is contained in:
154
app/Filament/Resources/AiStyles/AiStyleResource.php
Normal file
154
app/Filament/Resources/AiStyles/AiStyleResource.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiStyles;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
|
||||
use App\Models\AiStyle;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class AiStyleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiStyle::class;
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 31;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'AI Styles';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Style Basics')
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->required()
|
||||
->maxLength(120)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(120),
|
||||
TextInput::make('category')
|
||||
->maxLength(50),
|
||||
TextInput::make('sort')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->default(true),
|
||||
Toggle::make('is_premium')
|
||||
->default(false),
|
||||
Toggle::make('requires_source_image')
|
||||
->default(true),
|
||||
])
|
||||
->columns(3),
|
||||
Section::make('Provider Binding')
|
||||
->schema([
|
||||
Select::make('provider')
|
||||
->options([
|
||||
'runware' => 'runware.ai',
|
||||
])
|
||||
->required()
|
||||
->default('runware'),
|
||||
TextInput::make('provider_model')
|
||||
->maxLength(120),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Prompts')
|
||||
->schema([
|
||||
Textarea::make('description')
|
||||
->rows(2),
|
||||
Textarea::make('prompt_template')
|
||||
->rows(5),
|
||||
Textarea::make('negative_prompt_template')
|
||||
->rows(4),
|
||||
]),
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
KeyValue::make('metadata')
|
||||
->nullable(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('sort')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('key')
|
||||
->searchable()
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('provider')
|
||||
->badge(),
|
||||
Tables\Columns\TextColumn::make('provider_model')
|
||||
->toggleable(),
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
Tables\Columns\IconColumn::make('is_premium')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('sort')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->since()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active'),
|
||||
Tables\Filters\TernaryFilter::make('is_premium'),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make()
|
||||
->after(fn (array $data, AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
||||
static::class
|
||||
)),
|
||||
Actions\DeleteAction::make()
|
||||
->after(fn (AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ManageAiStyles::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php
Normal file
26
app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiStyles\Pages;
|
||||
|
||||
use App\Filament\Resources\AiStyles\AiStyleResource;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageAiStyles extends ManageRecords
|
||||
{
|
||||
protected static string $resource = AiStyleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'created',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
||||
static::class
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
177
app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php
Normal file
177
app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiEditingSettingsPage extends Page
|
||||
{
|
||||
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected string $view = 'filament.super-admin.pages.ai-editing-settings-page';
|
||||
|
||||
protected static null|string|\UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 30;
|
||||
|
||||
public static function getNavigationGroup(): \UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'AI Editing Settings';
|
||||
}
|
||||
|
||||
public bool $is_enabled = true;
|
||||
|
||||
public string $default_provider = 'runware';
|
||||
|
||||
public ?string $fallback_provider = null;
|
||||
|
||||
public string $runware_mode = 'live';
|
||||
|
||||
public bool $queue_auto_dispatch = false;
|
||||
|
||||
public string $queue_name = 'default';
|
||||
|
||||
public int $queue_max_polls = 6;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $blocked_terms = [];
|
||||
|
||||
public ?string $status_message = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$settings = AiEditingSetting::current();
|
||||
|
||||
$this->is_enabled = (bool) $settings->is_enabled;
|
||||
$this->default_provider = (string) ($settings->default_provider ?: 'runware');
|
||||
$this->fallback_provider = $settings->fallback_provider ? (string) $settings->fallback_provider : null;
|
||||
$this->runware_mode = (string) ($settings->runware_mode ?: 'live');
|
||||
$this->queue_auto_dispatch = (bool) $settings->queue_auto_dispatch;
|
||||
$this->queue_name = (string) ($settings->queue_name ?: 'default');
|
||||
$this->queue_max_polls = max(1, (int) ($settings->queue_max_polls ?: 6));
|
||||
$this->blocked_terms = array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
(array) $settings->blocked_terms
|
||||
)));
|
||||
$this->status_message = $settings->status_message ? (string) $settings->status_message : null;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Global Availability')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('is_enabled')
|
||||
->label('Enable AI editing globally'),
|
||||
Forms\Components\Textarea::make('status_message')
|
||||
->label('Disabled message')
|
||||
->maxLength(255)
|
||||
->rows(2)
|
||||
->helperText('Shown to guest and tenant clients when the feature is disabled.')
|
||||
->nullable(),
|
||||
]),
|
||||
Section::make('Provider')
|
||||
->schema([
|
||||
Forms\Components\Select::make('default_provider')
|
||||
->label('Default provider')
|
||||
->options([
|
||||
'runware' => 'runware.ai',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('fallback_provider')
|
||||
->label('Fallback provider')
|
||||
->maxLength(40)
|
||||
->helperText('Reserved for provider failover.'),
|
||||
Forms\Components\Select::make('runware_mode')
|
||||
->label('Runware mode')
|
||||
->options([
|
||||
'live' => 'Live API',
|
||||
'fake' => 'Fake mode (internal testing)',
|
||||
])
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Queue Orchestration')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('queue_auto_dispatch')
|
||||
->label('Auto-dispatch jobs after request creation'),
|
||||
Forms\Components\TextInput::make('queue_name')
|
||||
->label('Queue name')
|
||||
->required()
|
||||
->maxLength(60),
|
||||
Forms\Components\TextInput::make('queue_max_polls')
|
||||
->label('Max provider polls')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(50)
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Prompt Safety')
|
||||
->schema([
|
||||
Forms\Components\TagsInput::make('blocked_terms')
|
||||
->label('Blocked prompt terms')
|
||||
->helperText('Case-insensitive term match before queue dispatch.')
|
||||
->placeholder('Add blocked term'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$settings = AiEditingSetting::query()->firstOrNew(['id' => 1]);
|
||||
$settings->is_enabled = $this->is_enabled;
|
||||
$settings->default_provider = $this->default_provider;
|
||||
$settings->fallback_provider = $this->nullableString($this->fallback_provider);
|
||||
$settings->runware_mode = $this->runware_mode;
|
||||
$settings->queue_auto_dispatch = $this->queue_auto_dispatch;
|
||||
$settings->queue_name = $this->queue_name;
|
||||
$settings->queue_max_polls = max(1, $this->queue_max_polls);
|
||||
$settings->blocked_terms = array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
$this->blocked_terms
|
||||
)));
|
||||
$settings->status_message = $this->nullableString($this->status_message);
|
||||
$settings->save();
|
||||
|
||||
$changed = $settings->getChanges();
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'ai_editing.settings_updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('AI editing settings saved.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function nullableString(?string $value): ?string
|
||||
{
|
||||
$trimmed = trim((string) $value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
}
|
||||
468
app/Http/Controllers/Api/EventPublicAiEditController.php
Normal file
468
app/Http/Controllers/Api/EventPublicAiEditController.php
Normal file
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Requests\Api\GuestAiEditStoreRequest;
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiStyleAccessService;
|
||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EventPublicAiEditController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventJoinTokenService $joinTokenService,
|
||||
private readonly AiSafetyPolicyService $safetyPolicy,
|
||||
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
||||
private readonly AiStylingEntitlementService $entitlements,
|
||||
private readonly EventAiEditingPolicyService $eventPolicy,
|
||||
private readonly AiStyleAccessService $styleAccess,
|
||||
) {}
|
||||
|
||||
public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse
|
||||
{
|
||||
$event = $this->resolvePublishedEvent($token);
|
||||
if ($event instanceof JsonResponse) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$photoModel = Photo::query()
|
||||
->whereKey($photo)
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $photoModel) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
if ($photoModel->status !== 'approved') {
|
||||
return ApiError::response(
|
||||
'photo_not_eligible',
|
||||
'Photo not eligible',
|
||||
'Only approved photos can be used for AI edits.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
if (! $policy['enabled']) {
|
||||
return ApiError::response(
|
||||
'event_feature_disabled',
|
||||
'Feature disabled for this event',
|
||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$style = $this->resolveStyleByKey($request->input('style_key'));
|
||||
if ($request->filled('style_key') && ! $style) {
|
||||
return ApiError::response(
|
||||
'style_not_found',
|
||||
'Style not found',
|
||||
'The selected style is not available.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$style
|
||||
&& (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style))
|
||||
) {
|
||||
return ApiError::response(
|
||||
'style_not_allowed',
|
||||
'Style not allowed',
|
||||
$policy['policy_message'] ?? 'This style is not allowed for this event.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
[
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$prompt = (string) ($request->input('prompt') ?: $style?->prompt_template ?: '');
|
||||
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style?->negative_prompt_template ?: '');
|
||||
$providerModel = $request->input('provider_model') ?: $style?->provider_model;
|
||||
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
|
||||
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
|
||||
$sessionId = $this->normalizeOptionalString((string) $request->input('session_id', ''));
|
||||
|
||||
$idempotencyKey = $this->resolveIdempotencyKey(
|
||||
$request->input('idempotency_key'),
|
||||
$request->header('X-Idempotency-Key'),
|
||||
$photoModel,
|
||||
$style,
|
||||
$prompt,
|
||||
$deviceId,
|
||||
$sessionId
|
||||
);
|
||||
|
||||
$attributes = [
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photoModel->id,
|
||||
'style_id' => $style?->id,
|
||||
'provider' => $this->runtimeConfig->defaultProvider(),
|
||||
'provider_model' => $providerModel,
|
||||
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
|
||||
'safety_state' => $safetyDecision->state,
|
||||
'prompt' => $prompt,
|
||||
'negative_prompt' => $negativePrompt,
|
||||
'input_image_path' => $photoModel->file_path,
|
||||
'requested_by_device_id' => $deviceId,
|
||||
'requested_by_session_id' => $sessionId,
|
||||
'safety_reasons' => $safetyDecision->reasonCodes,
|
||||
'failure_code' => $safetyDecision->failureCode,
|
||||
'failure_message' => $safetyDecision->failureMessage,
|
||||
'queued_at' => now(),
|
||||
'completed_at' => $safetyDecision->blocked ? now() : null,
|
||||
'metadata' => $request->input('metadata', []),
|
||||
];
|
||||
|
||||
$editRequest = AiEditRequest::query()->firstOrCreate(
|
||||
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
|
||||
$attributes
|
||||
);
|
||||
|
||||
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
|
||||
$editRequest,
|
||||
$event,
|
||||
$photoModel,
|
||||
$style?->id,
|
||||
$prompt,
|
||||
$negativePrompt,
|
||||
$providerModel,
|
||||
$deviceId,
|
||||
$sessionId
|
||||
)) {
|
||||
return ApiError::response(
|
||||
'idempotency_conflict',
|
||||
'Idempotency conflict',
|
||||
'The provided idempotency key is already in use for another request.',
|
||||
Response::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$editRequest->wasRecentlyCreated
|
||||
&& ! $safetyDecision->blocked
|
||||
&& $this->runtimeConfig->queueAutoDispatch()
|
||||
) {
|
||||
ProcessAiEditRequest::dispatch($editRequest->id)
|
||||
->onQueue($this->runtimeConfig->queueName());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
|
||||
'duplicate' => ! $editRequest->wasRecentlyCreated,
|
||||
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
|
||||
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $token, int $requestId): JsonResponse
|
||||
{
|
||||
$event = $this->resolvePublishedEvent($token);
|
||||
if ($event instanceof JsonResponse) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$editRequest = AiEditRequest::query()
|
||||
->with(['style', 'outputs'])
|
||||
->whereKey($requestId)
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $editRequest) {
|
||||
return ApiError::response(
|
||||
'edit_request_not_found',
|
||||
'Edit request not found',
|
||||
'The specified AI edit request could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
|
||||
if ($editRequest->requested_by_device_id && $deviceId && $editRequest->requested_by_device_id !== $deviceId) {
|
||||
return ApiError::response(
|
||||
'forbidden_request_scope',
|
||||
'Forbidden',
|
||||
'This AI edit request belongs to another device.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->serializeRequest($editRequest),
|
||||
]);
|
||||
}
|
||||
|
||||
public function styles(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$event = $this->resolvePublishedEvent($token);
|
||||
if ($event instanceof JsonResponse) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
if (! $policy['enabled']) {
|
||||
return ApiError::response(
|
||||
'event_feature_disabled',
|
||||
'Feature disabled for this event',
|
||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$styles = $this->eventPolicy->filterStyles(
|
||||
$event,
|
||||
AiStyle::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
);
|
||||
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
|
||||
|
||||
return response()->json([
|
||||
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
|
||||
'meta' => [
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
'allow_custom_prompt' => $policy['allow_custom_prompt'],
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
'policy_message' => $policy['policy_message'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolvePublishedEvent(string $token): Event|JsonResponse
|
||||
{
|
||||
$joinToken = $this->joinTokenService->findActiveToken($token);
|
||||
|
||||
if (! $joinToken) {
|
||||
return ApiError::response(
|
||||
'invalid_token',
|
||||
'Invalid token',
|
||||
'The provided event token is invalid or expired.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$event = Event::query()
|
||||
->whereKey($joinToken->event_id)
|
||||
->where('status', 'published')
|
||||
->first();
|
||||
|
||||
if (! $event) {
|
||||
return ApiError::response(
|
||||
'event_not_public',
|
||||
'Event not public',
|
||||
'This event is not publicly accessible.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function resolveStyleByKey(?string $styleKey): ?AiStyle
|
||||
{
|
||||
$key = $this->normalizeOptionalString((string) ($styleKey ?? ''));
|
||||
if (! $key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AiStyle::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function normalizeOptionalString(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
|
||||
private function resolveIdempotencyKey(
|
||||
mixed $bodyKey,
|
||||
mixed $headerKey,
|
||||
Photo $photo,
|
||||
?AiStyle $style,
|
||||
string $prompt,
|
||||
?string $deviceId,
|
||||
?string $sessionId
|
||||
): string {
|
||||
$candidate = $this->normalizeOptionalString((string) ($bodyKey ?: $headerKey ?: ''));
|
||||
if ($candidate) {
|
||||
return Str::limit($candidate, 120, '');
|
||||
}
|
||||
|
||||
return substr(hash('sha256', implode('|', [
|
||||
(string) $photo->event_id,
|
||||
(string) $photo->id,
|
||||
(string) ($style?->id ?? ''),
|
||||
trim($prompt),
|
||||
(string) ($deviceId ?? ''),
|
||||
(string) ($sessionId ?? ''),
|
||||
])), 0, 120);
|
||||
}
|
||||
|
||||
private function isIdempotencyConflict(
|
||||
AiEditRequest $request,
|
||||
Event $event,
|
||||
Photo $photo,
|
||||
?int $styleId,
|
||||
string $prompt,
|
||||
string $negativePrompt,
|
||||
?string $providerModel,
|
||||
?string $deviceId,
|
||||
?string $sessionId
|
||||
): bool {
|
||||
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($request->style_id ?? 0) !== (int) ($styleId ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->requested_by_device_id) !== $this->normalizeOptionalString($deviceId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->normalizeOptionalString($request->requested_by_session_id) !== $this->normalizeOptionalString($sessionId);
|
||||
}
|
||||
|
||||
private function serializeStyle(AiStyle $style): array
|
||||
{
|
||||
return [
|
||||
'id' => $style->id,
|
||||
'key' => $style->key,
|
||||
'name' => $style->name,
|
||||
'category' => $style->category,
|
||||
'description' => $style->description,
|
||||
'provider' => $style->provider,
|
||||
'provider_model' => $style->provider_model,
|
||||
'requires_source_image' => $style->requires_source_image,
|
||||
'is_premium' => $style->is_premium,
|
||||
'metadata' => $style->metadata ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeRequest(AiEditRequest $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $request->id,
|
||||
'event_id' => $request->event_id,
|
||||
'photo_id' => $request->photo_id,
|
||||
'style' => $request->style ? [
|
||||
'id' => $request->style->id,
|
||||
'key' => $request->style->key,
|
||||
'name' => $request->style->name,
|
||||
] : null,
|
||||
'provider' => $request->provider,
|
||||
'provider_model' => $request->provider_model,
|
||||
'status' => $request->status,
|
||||
'safety_state' => $request->safety_state,
|
||||
'safety_reasons' => $request->safety_reasons ?? [],
|
||||
'failure_code' => $request->failure_code,
|
||||
'failure_message' => $request->failure_message,
|
||||
'queued_at' => $request->queued_at?->toIso8601String(),
|
||||
'started_at' => $request->started_at?->toIso8601String(),
|
||||
'completed_at' => $request->completed_at?->toIso8601String(),
|
||||
'outputs' => $request->outputs->map(fn ($output) => [
|
||||
'id' => $output->id,
|
||||
'storage_disk' => $output->storage_disk,
|
||||
'storage_path' => $output->storage_path,
|
||||
'provider_url' => $output->provider_url,
|
||||
'mime_type' => $output->mime_type,
|
||||
'width' => $output->width,
|
||||
'height' => $output->height,
|
||||
'is_primary' => $output->is_primary,
|
||||
'safety_state' => $output->safety_state,
|
||||
'safety_reasons' => $output->safety_reasons ?? [],
|
||||
'generated_at' => $output->generated_at?->toIso8601String(),
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
488
app/Http/Controllers/Api/Tenant/AiEditController.php
Normal file
488
app/Http/Controllers/Api/Tenant/AiEditController.php
Normal file
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\AiEditIndexRequest;
|
||||
use App\Http\Requests\Tenant\AiEditStoreRequest;
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiStyleAccessService;
|
||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AiEditController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiSafetyPolicyService $safetyPolicy,
|
||||
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
||||
private readonly AiStylingEntitlementService $entitlements,
|
||||
private readonly EventAiEditingPolicyService $eventPolicy,
|
||||
private readonly AiStyleAccessService $styleAccess,
|
||||
) {}
|
||||
|
||||
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$perPage = (int) $request->input('per_page', 20);
|
||||
$status = (string) $request->input('status', '');
|
||||
$safetyState = (string) $request->input('safety_state', '');
|
||||
|
||||
$query = AiEditRequest::query()
|
||||
->with(['style', 'outputs'])
|
||||
->where('event_id', $event->id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($safetyState !== '') {
|
||||
$query->where('safety_state', $safetyState);
|
||||
}
|
||||
|
||||
$requests = $query->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($requests->items())->map(fn (AiEditRequest $item) => $this->serializeRequest($item))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $requests->currentPage(),
|
||||
'per_page' => $requests->perPage(),
|
||||
'total' => $requests->total(),
|
||||
'last_page' => $requests->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function styles(Request $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$styles = AiStyle::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
$styles = $this->eventPolicy->filterStyles($event, $styles);
|
||||
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
|
||||
|
||||
return response()->json([
|
||||
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
|
||||
'meta' => [
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
'event_enabled' => $policy['enabled'],
|
||||
'allow_custom_prompt' => $policy['allow_custom_prompt'],
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
'policy_message' => $policy['policy_message'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function summary(Request $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
|
||||
$statusCounts = (clone $baseQuery)
|
||||
->select('status', DB::raw('count(*) as aggregate'))
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
$safetyCounts = (clone $baseQuery)
|
||||
->select('safety_state', DB::raw('count(*) as aggregate'))
|
||||
->groupBy('safety_state')
|
||||
->pluck('aggregate', 'safety_state')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$lastRequestedAt = (clone $baseQuery)->max('created_at');
|
||||
$total = array_sum($statusCounts);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'event_id' => $event->id,
|
||||
'total' => $total,
|
||||
'status_counts' => $statusCounts,
|
||||
'safety_counts' => $safetyCounts,
|
||||
'failed_total' => (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0)),
|
||||
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(AiEditStoreRequest $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$photo = Photo::query()
|
||||
->whereKey((int) $request->input('photo_id'))
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $photo) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$style = $this->resolveStyle($request->input('style_id'), $request->input('style_key'));
|
||||
if (! $style) {
|
||||
return ApiError::response(
|
||||
'style_not_found',
|
||||
'Style not found',
|
||||
'The selected style is not available.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
if (! $policy['enabled']) {
|
||||
return ApiError::response(
|
||||
'event_feature_disabled',
|
||||
'Feature disabled for this event',
|
||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
|
||||
return ApiError::response(
|
||||
'style_not_allowed',
|
||||
'Style not allowed',
|
||||
$policy['policy_message'] ?? 'This style is not allowed for this event.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
[
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$prompt = (string) ($request->input('prompt') ?: $style->prompt_template ?: '');
|
||||
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style->negative_prompt_template ?: '');
|
||||
$providerModel = $request->input('provider_model') ?: $style->provider_model;
|
||||
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
|
||||
$requestedByUserId = $request->user()?->id;
|
||||
|
||||
$idempotencyKey = $this->resolveIdempotencyKey(
|
||||
$request->input('idempotency_key'),
|
||||
$request->header('X-Idempotency-Key'),
|
||||
$event,
|
||||
$photo,
|
||||
$style,
|
||||
$prompt,
|
||||
$requestedByUserId
|
||||
);
|
||||
|
||||
$editRequest = AiEditRequest::query()->firstOrCreate(
|
||||
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'requested_by_user_id' => $requestedByUserId,
|
||||
'provider' => $this->runtimeConfig->defaultProvider(),
|
||||
'provider_model' => $providerModel,
|
||||
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
|
||||
'safety_state' => $safetyDecision->state,
|
||||
'prompt' => $prompt,
|
||||
'negative_prompt' => $negativePrompt,
|
||||
'input_image_path' => $photo->file_path,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'safety_reasons' => $safetyDecision->reasonCodes,
|
||||
'failure_code' => $safetyDecision->failureCode,
|
||||
'failure_message' => $safetyDecision->failureMessage,
|
||||
'queued_at' => now(),
|
||||
'completed_at' => $safetyDecision->blocked ? now() : null,
|
||||
'metadata' => $request->input('metadata', []),
|
||||
]
|
||||
);
|
||||
|
||||
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
|
||||
$editRequest,
|
||||
$event,
|
||||
$photo,
|
||||
$style,
|
||||
$prompt,
|
||||
$negativePrompt,
|
||||
$providerModel,
|
||||
$requestedByUserId
|
||||
)) {
|
||||
return ApiError::response(
|
||||
'idempotency_conflict',
|
||||
'Idempotency conflict',
|
||||
'The provided idempotency key is already in use for another request.',
|
||||
Response::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$editRequest->wasRecentlyCreated
|
||||
&& ! $safetyDecision->blocked
|
||||
&& $this->runtimeConfig->queueAutoDispatch()
|
||||
) {
|
||||
ProcessAiEditRequest::dispatch($editRequest->id)
|
||||
->onQueue($this->runtimeConfig->queueName());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
|
||||
'duplicate' => ! $editRequest->wasRecentlyCreated,
|
||||
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
|
||||
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $eventSlug, int $aiEditRequest): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$editRequest = AiEditRequest::query()
|
||||
->with(['style', 'outputs'])
|
||||
->whereKey($aiEditRequest)
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $editRequest) {
|
||||
return ApiError::response(
|
||||
'edit_request_not_found',
|
||||
'Edit request not found',
|
||||
'The specified AI edit request could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->serializeRequest($editRequest),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveTenantEventOrFail(Request $request, string $eventSlug): Event
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
return Event::query()
|
||||
->where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function resolveStyle(mixed $styleId, mixed $styleKey): ?AiStyle
|
||||
{
|
||||
if ($styleId !== null) {
|
||||
return AiStyle::query()
|
||||
->whereKey((int) $styleId)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
$key = trim((string) ($styleKey ?? ''));
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AiStyle::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveIdempotencyKey(
|
||||
mixed $bodyKey,
|
||||
mixed $headerKey,
|
||||
Event $event,
|
||||
Photo $photo,
|
||||
AiStyle $style,
|
||||
string $prompt,
|
||||
mixed $requestedByUserId
|
||||
): string {
|
||||
$candidate = trim((string) ($bodyKey ?: $headerKey ?: ''));
|
||||
if ($candidate !== '') {
|
||||
return Str::limit($candidate, 120, '');
|
||||
}
|
||||
|
||||
return substr(hash('sha256', implode('|', [
|
||||
(string) $event->id,
|
||||
(string) $photo->id,
|
||||
(string) $style->id,
|
||||
trim($prompt),
|
||||
(string) ($this->normalizeUserId($requestedByUserId)),
|
||||
])), 0, 120);
|
||||
}
|
||||
|
||||
private function normalizeUserId(mixed $userId): ?string
|
||||
{
|
||||
if (! is_int($userId) && ! is_string($userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $userId);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function normalizeOptionalString(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
|
||||
private function isIdempotencyConflict(
|
||||
AiEditRequest $request,
|
||||
Event $event,
|
||||
Photo $photo,
|
||||
AiStyle $style,
|
||||
string $prompt,
|
||||
string $negativePrompt,
|
||||
?string $providerModel,
|
||||
mixed $requestedByUserId
|
||||
): bool {
|
||||
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($request->style_id ?? 0) !== (int) $style->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->normalizeUserId($request->requested_by_user_id) !== $this->normalizeUserId($requestedByUserId);
|
||||
}
|
||||
|
||||
private function serializeStyle(AiStyle $style): array
|
||||
{
|
||||
return [
|
||||
'id' => $style->id,
|
||||
'key' => $style->key,
|
||||
'name' => $style->name,
|
||||
'category' => $style->category,
|
||||
'description' => $style->description,
|
||||
'provider' => $style->provider,
|
||||
'provider_model' => $style->provider_model,
|
||||
'requires_source_image' => $style->requires_source_image,
|
||||
'is_premium' => $style->is_premium,
|
||||
'metadata' => $style->metadata ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeRequest(AiEditRequest $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $request->id,
|
||||
'event_id' => $request->event_id,
|
||||
'photo_id' => $request->photo_id,
|
||||
'style' => $request->style ? [
|
||||
'id' => $request->style->id,
|
||||
'key' => $request->style->key,
|
||||
'name' => $request->style->name,
|
||||
] : null,
|
||||
'provider' => $request->provider,
|
||||
'provider_model' => $request->provider_model,
|
||||
'status' => $request->status,
|
||||
'safety_state' => $request->safety_state,
|
||||
'safety_reasons' => $request->safety_reasons ?? [],
|
||||
'failure_code' => $request->failure_code,
|
||||
'failure_message' => $request->failure_message,
|
||||
'queued_at' => $request->queued_at?->toIso8601String(),
|
||||
'started_at' => $request->started_at?->toIso8601String(),
|
||||
'completed_at' => $request->completed_at?->toIso8601String(),
|
||||
'outputs' => $request->outputs->map(fn ($output) => [
|
||||
'id' => $output->id,
|
||||
'storage_disk' => $output->storage_disk,
|
||||
'storage_path' => $output->storage_path,
|
||||
'provider_url' => $output->provider_url,
|
||||
'mime_type' => $output->mime_type,
|
||||
'width' => $output->width,
|
||||
'height' => $output->height,
|
||||
'is_primary' => $output->is_primary,
|
||||
'safety_state' => $output->safety_state,
|
||||
'safety_reasons' => $output->safety_reasons ?? [],
|
||||
'generated_at' => $output->generated_at?->toIso8601String(),
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
26
app/Http/Requests/Api/GuestAiEditStoreRequest.php
Normal file
26
app/Http/Requests/Api/GuestAiEditStoreRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GuestAiEditStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'style_key' => ['nullable', 'string', 'max:120', 'required_without:prompt'],
|
||||
'prompt' => ['nullable', 'string', 'max:2000', 'required_without:style_key'],
|
||||
'negative_prompt' => ['nullable', 'string', 'max:2000'],
|
||||
'provider_model' => ['nullable', 'string', 'max:120'],
|
||||
'idempotency_key' => ['nullable', 'string', 'max:120'],
|
||||
'session_id' => ['nullable', 'string', 'max:191'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/Tenant/AiEditIndexRequest.php
Normal file
22
app/Http/Requests/Tenant/AiEditIndexRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AiEditIndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'safety_state' => ['nullable', 'string', 'max:30'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/Tenant/AiEditStoreRequest.php
Normal file
27
app/Http/Requests/Tenant/AiEditStoreRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AiEditStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'photo_id' => ['required', 'integer', 'exists:photos,id'],
|
||||
'style_id' => ['nullable', 'integer', 'exists:ai_styles,id', 'required_without:style_key'],
|
||||
'style_key' => ['nullable', 'string', 'max:120', 'required_without:style_id'],
|
||||
'prompt' => ['nullable', 'string', 'max:2000'],
|
||||
'negative_prompt' => ['nullable', 'string', 'max:2000'],
|
||||
'provider_model' => ['nullable', 'string', 'max:120'],
|
||||
'idempotency_key' => ['nullable', 'string', 'max:120'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Tenant/BillingAddonHistoryRequest.php
Normal file
24
app/Http/Requests/Tenant/BillingAddonHistoryRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BillingAddonHistoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'event_id' => ['nullable', 'integer', 'min:1'],
|
||||
'event_slug' => ['nullable', 'string', 'max:191'],
|
||||
'status' => ['nullable', 'in:pending,completed,failed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
148
app/Jobs/PollAiEditRequest.php
Normal file
148
app/Jobs/PollAiEditRequest.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiImageProviderManager;
|
||||
use App\Services\AiEditing\AiUsageLedgerService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PollAiEditRequest implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public array $backoff = [20, 60, 120];
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $requestId,
|
||||
private readonly string $providerTaskId,
|
||||
private readonly int $pollAttempt = 1,
|
||||
) {
|
||||
$this->onQueue((string) config('ai-editing.queue.name', 'default'));
|
||||
}
|
||||
|
||||
public function handle(
|
||||
AiImageProviderManager $providers,
|
||||
AiSafetyPolicyService $safetyPolicy,
|
||||
AiEditingRuntimeConfig $runtimeConfig,
|
||||
AiUsageLedgerService $usageLedger
|
||||
): void {
|
||||
$request = AiEditRequest::query()->with('outputs')->find($this->requestId);
|
||||
if (! $request || $request->status !== AiEditRequest::STATUS_PROCESSING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run = AiProviderRun::query()->create([
|
||||
'request_id' => $request->id,
|
||||
'provider' => $request->provider,
|
||||
'attempt' => ((int) $request->providerRuns()->max('attempt')) + 1,
|
||||
'provider_task_id' => $this->providerTaskId,
|
||||
'status' => AiProviderRun::STATUS_RUNNING,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $providers->forProvider($request->provider)->poll($request, $this->providerTaskId);
|
||||
|
||||
$run->forceFill([
|
||||
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
|
||||
'http_status' => $result->httpStatus,
|
||||
'finished_at' => $result->status === 'processing' ? null : now(),
|
||||
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
|
||||
'cost_usd' => $result->costUsd,
|
||||
'request_payload' => $result->requestPayload,
|
||||
'response_payload' => $result->responsePayload,
|
||||
'error_message' => $result->failureMessage,
|
||||
])->save();
|
||||
|
||||
if ($result->status === 'succeeded') {
|
||||
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
|
||||
if ($outputDecision->blocked) {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_BLOCKED,
|
||||
'safety_state' => $outputDecision->state,
|
||||
'safety_reasons' => $outputDecision->reasonCodes,
|
||||
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
|
||||
'failure_message' => $outputDecision->failureMessage,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($result->outputs as $output) {
|
||||
AiEditOutput::query()->updateOrCreate(
|
||||
[
|
||||
'request_id' => $request->id,
|
||||
'provider_asset_id' => (string) Arr::get($output, 'provider_asset_id', $this->providerTaskId),
|
||||
],
|
||||
[
|
||||
'provider_url' => Arr::get($output, 'provider_url'),
|
||||
'mime_type' => Arr::get($output, 'mime_type'),
|
||||
'width' => Arr::get($output, 'width'),
|
||||
'height' => Arr::get($output, 'height'),
|
||||
'is_primary' => true,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'generated_at' => now(),
|
||||
'metadata' => ['provider' => $request->provider],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'failure_code' => null,
|
||||
'failure_message' => null,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
|
||||
'source' => 'poll_job',
|
||||
'poll_attempt' => $this->pollAttempt,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'processing') {
|
||||
$maxPolls = $runtimeConfig->maxPolls();
|
||||
if ($this->pollAttempt < $maxPolls) {
|
||||
self::dispatch($request->id, $this->providerTaskId, $this->pollAttempt + 1)
|
||||
->delay(now()->addSeconds(20))
|
||||
->onQueue($runtimeConfig->queueName());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
|
||||
'safety_state' => $result->safetyState ?? $request->safety_state,
|
||||
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
|
||||
'failure_code' => $result->failureCode,
|
||||
'failure_message' => $result->failureMessage,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
180
app/Jobs/ProcessAiEditRequest.php
Normal file
180
app/Jobs/ProcessAiEditRequest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiImageProviderManager;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\AiUsageLedgerService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProcessAiEditRequest implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public array $backoff = [30, 120, 300];
|
||||
|
||||
public int $timeout = 90;
|
||||
|
||||
public function __construct(private readonly int $requestId)
|
||||
{
|
||||
$queue = (string) config('ai-editing.queue.name', 'default');
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
|
||||
public function handle(
|
||||
AiImageProviderManager $providers,
|
||||
AiSafetyPolicyService $safetyPolicy,
|
||||
AiEditingRuntimeConfig $runtimeConfig,
|
||||
AiUsageLedgerService $usageLedger
|
||||
): void {
|
||||
$request = AiEditRequest::query()->with('style')->find($this->requestId);
|
||||
if (! $request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->status === AiEditRequest::STATUS_QUEUED) {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'started_at' => $request->started_at ?: now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$attempt = ((int) $request->providerRuns()->max('attempt')) + 1;
|
||||
$providerRun = AiProviderRun::query()->create([
|
||||
'request_id' => $request->id,
|
||||
'provider' => $request->provider,
|
||||
'attempt' => $attempt,
|
||||
'status' => AiProviderRun::STATUS_RUNNING,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $providers->forProvider($request->provider)->submit($request);
|
||||
|
||||
$this->finalizeProviderRun($providerRun, $result);
|
||||
$this->applyProviderResult($request->fresh(['outputs']), $result, $safetyPolicy, $runtimeConfig, $usageLedger);
|
||||
}
|
||||
|
||||
private function finalizeProviderRun(AiProviderRun $run, AiProviderResult $result): void
|
||||
{
|
||||
$run->forceFill([
|
||||
'provider_task_id' => $result->providerTaskId,
|
||||
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
|
||||
'http_status' => $result->httpStatus,
|
||||
'finished_at' => $result->status === 'processing' ? null : now(),
|
||||
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
|
||||
'cost_usd' => $result->costUsd,
|
||||
'request_payload' => $result->requestPayload,
|
||||
'response_payload' => $result->responsePayload,
|
||||
'error_message' => $result->failureMessage,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function applyProviderResult(
|
||||
AiEditRequest $request,
|
||||
AiProviderResult $result,
|
||||
AiSafetyPolicyService $safetyPolicy,
|
||||
AiEditingRuntimeConfig $runtimeConfig,
|
||||
AiUsageLedgerService $usageLedger
|
||||
): void {
|
||||
if ($result->status === 'succeeded') {
|
||||
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
|
||||
if ($outputDecision->blocked) {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_BLOCKED,
|
||||
'safety_state' => $outputDecision->state,
|
||||
'safety_reasons' => $outputDecision->reasonCodes,
|
||||
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
|
||||
'failure_message' => $outputDecision->failureMessage,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($request, $result): void {
|
||||
foreach ($result->outputs as $output) {
|
||||
AiEditOutput::query()->updateOrCreate(
|
||||
[
|
||||
'request_id' => $request->id,
|
||||
'provider_asset_id' => (string) Arr::get($output, 'provider_asset_id', ''),
|
||||
],
|
||||
[
|
||||
'provider_url' => Arr::get($output, 'provider_url'),
|
||||
'mime_type' => Arr::get($output, 'mime_type'),
|
||||
'width' => Arr::get($output, 'width'),
|
||||
'height' => Arr::get($output, 'height'),
|
||||
'is_primary' => true,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'generated_at' => now(),
|
||||
'metadata' => ['provider' => $request->provider],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'failure_code' => null,
|
||||
'failure_message' => null,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
|
||||
'source' => 'process_job',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'processing') {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'failure_code' => null,
|
||||
'failure_message' => null,
|
||||
])->save();
|
||||
|
||||
if ($result->providerTaskId !== null && $result->providerTaskId !== '') {
|
||||
PollAiEditRequest::dispatch($request->id, $result->providerTaskId, 1)
|
||||
->delay(now()->addSeconds(20))
|
||||
->onQueue($runtimeConfig->queueName());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
|
||||
'safety_state' => $result->safetyState ?? $request->safety_state,
|
||||
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
|
||||
'failure_code' => $result->failureCode,
|
||||
'failure_message' => $result->failureMessage,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
51
app/Models/AiEditOutput.php
Normal file
51
app/Models/AiEditOutput.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiEditOutput extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'request_id',
|
||||
'photo_id',
|
||||
'storage_disk',
|
||||
'storage_path',
|
||||
'mime_type',
|
||||
'width',
|
||||
'height',
|
||||
'bytes',
|
||||
'checksum',
|
||||
'provider_asset_id',
|
||||
'provider_url',
|
||||
'is_primary',
|
||||
'safety_state',
|
||||
'safety_reasons',
|
||||
'generated_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_primary' => 'boolean',
|
||||
'safety_reasons' => 'array',
|
||||
'metadata' => 'array',
|
||||
'generated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function request(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiEditRequest::class, 'request_id');
|
||||
}
|
||||
|
||||
public function photo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Photo::class);
|
||||
}
|
||||
}
|
||||
103
app/Models/AiEditRequest.php
Normal file
103
app/Models/AiEditRequest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AiEditRequest extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_SUCCEEDED = 'succeeded';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_BLOCKED = 'blocked';
|
||||
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'photo_id',
|
||||
'style_id',
|
||||
'requested_by_user_id',
|
||||
'provider',
|
||||
'provider_model',
|
||||
'status',
|
||||
'safety_state',
|
||||
'prompt',
|
||||
'negative_prompt',
|
||||
'input_image_path',
|
||||
'requested_by_device_id',
|
||||
'requested_by_session_id',
|
||||
'idempotency_key',
|
||||
'safety_reasons',
|
||||
'failure_code',
|
||||
'failure_message',
|
||||
'queued_at',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'safety_reasons' => 'array',
|
||||
'metadata' => 'array',
|
||||
'queued_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function photo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Photo::class);
|
||||
}
|
||||
|
||||
public function style(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiStyle::class, 'style_id');
|
||||
}
|
||||
|
||||
public function requestedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requested_by_user_id');
|
||||
}
|
||||
|
||||
public function outputs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiEditOutput::class, 'request_id');
|
||||
}
|
||||
|
||||
public function providerRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiProviderRun::class, 'request_id');
|
||||
}
|
||||
|
||||
public function usageLedgers(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiUsageLedger::class, 'request_id');
|
||||
}
|
||||
}
|
||||
63
app/Models/AiEditingSetting.php
Normal file
63
app/Models/AiEditingSetting.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class AiEditingSetting extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_enabled' => 'boolean',
|
||||
'queue_auto_dispatch' => 'boolean',
|
||||
'queue_max_polls' => 'integer',
|
||||
'blocked_terms' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn () => static::flushCache());
|
||||
static::deleted(fn () => static::flushCache());
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
/** @var self */
|
||||
return Cache::remember('ai_editing.settings', now()->addMinutes(10), static function (): self {
|
||||
try {
|
||||
return static::query()->firstOrCreate(['id' => 1], static::defaults());
|
||||
} catch (Throwable) {
|
||||
return new static(static::defaults());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'is_enabled' => true,
|
||||
'default_provider' => (string) config('ai-editing.default_provider', 'runware'),
|
||||
'fallback_provider' => null,
|
||||
'runware_mode' => (string) config('ai-editing.providers.runware.mode', 'live'),
|
||||
'queue_auto_dispatch' => (bool) config('ai-editing.queue.auto_dispatch', false),
|
||||
'queue_name' => (string) config('ai-editing.queue.name', 'default'),
|
||||
'queue_max_polls' => max(1, (int) config('ai-editing.queue.max_polls', 6)),
|
||||
'blocked_terms' => array_values(array_filter((array) config('ai-editing.safety.prompt.blocked_terms', []))),
|
||||
'status_message' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function flushCache(): void
|
||||
{
|
||||
Cache::forget('ai_editing.settings');
|
||||
}
|
||||
}
|
||||
56
app/Models/AiProviderRun.php
Normal file
56
app/Models/AiProviderRun.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiProviderRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCEEDED = 'succeeded';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'request_id',
|
||||
'provider',
|
||||
'attempt',
|
||||
'provider_task_id',
|
||||
'status',
|
||||
'http_status',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'duration_ms',
|
||||
'cost_usd',
|
||||
'tokens_input',
|
||||
'tokens_output',
|
||||
'request_payload',
|
||||
'response_payload',
|
||||
'error_message',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'request_payload' => 'array',
|
||||
'response_payload' => 'array',
|
||||
'metadata' => 'array',
|
||||
'cost_usd' => 'decimal:5',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function request(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiEditRequest::class, 'request_id');
|
||||
}
|
||||
}
|
||||
44
app/Models/AiStyle.php
Normal file
44
app/Models/AiStyle.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AiStyle extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'name',
|
||||
'category',
|
||||
'description',
|
||||
'prompt_template',
|
||||
'negative_prompt_template',
|
||||
'provider',
|
||||
'provider_model',
|
||||
'requires_source_image',
|
||||
'is_premium',
|
||||
'is_active',
|
||||
'sort',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'requires_source_image' => 'boolean',
|
||||
'is_premium' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort' => 'integer',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function editRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiEditRequest::class, 'style_id');
|
||||
}
|
||||
}
|
||||
60
app/Models/AiUsageLedger.php
Normal file
60
app/Models/AiUsageLedger.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiUsageLedger extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_DEBIT = 'debit';
|
||||
|
||||
public const TYPE_CREDIT = 'credit';
|
||||
|
||||
public const TYPE_REFUND = 'refund';
|
||||
|
||||
public const TYPE_ADJUSTMENT = 'adjustment';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'request_id',
|
||||
'entry_type',
|
||||
'quantity',
|
||||
'unit_cost_usd',
|
||||
'amount_usd',
|
||||
'currency',
|
||||
'package_context',
|
||||
'notes',
|
||||
'recorded_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'unit_cost_usd' => 'decimal:5',
|
||||
'amount_usd' => 'decimal:5',
|
||||
'recorded_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function request(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiEditRequest::class, 'request_id');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 : '');
|
||||
|
||||
65
app/Services/AiEditing/AiEditingRuntimeConfig.php
Normal file
65
app/Services/AiEditing/AiEditingRuntimeConfig.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiEditingSetting;
|
||||
|
||||
class AiEditingRuntimeConfig
|
||||
{
|
||||
public function settings(): AiEditingSetting
|
||||
{
|
||||
return AiEditingSetting::current();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) $this->settings()->is_enabled;
|
||||
}
|
||||
|
||||
public function defaultProvider(): string
|
||||
{
|
||||
return (string) ($this->settings()->default_provider ?: 'runware');
|
||||
}
|
||||
|
||||
public function queueAutoDispatch(): bool
|
||||
{
|
||||
return (bool) $this->settings()->queue_auto_dispatch;
|
||||
}
|
||||
|
||||
public function queueName(): string
|
||||
{
|
||||
$queueName = trim((string) ($this->settings()->queue_name ?: ''));
|
||||
|
||||
return $queueName !== '' ? $queueName : 'default';
|
||||
}
|
||||
|
||||
public function maxPolls(): int
|
||||
{
|
||||
return max(1, (int) $this->settings()->queue_max_polls);
|
||||
}
|
||||
|
||||
public function runwareMode(): string
|
||||
{
|
||||
$mode = trim((string) ($this->settings()->runware_mode ?: ''));
|
||||
|
||||
return $mode !== '' ? $mode : 'live';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function blockedTerms(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
(array) $this->settings()->blocked_terms
|
||||
)));
|
||||
}
|
||||
|
||||
public function statusMessage(): ?string
|
||||
{
|
||||
$message = trim((string) ($this->settings()->status_message ?? ''));
|
||||
|
||||
return $message !== '' ? $message : null;
|
||||
}
|
||||
}
|
||||
18
app/Services/AiEditing/AiImageProviderManager.php
Normal file
18
app/Services/AiEditing/AiImageProviderManager.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Services\AiEditing\Contracts\AiImageProvider;
|
||||
use App\Services\AiEditing\Providers\NullAiImageProvider;
|
||||
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
|
||||
|
||||
class AiImageProviderManager
|
||||
{
|
||||
public function forProvider(string $provider): AiImageProvider
|
||||
{
|
||||
return match ($provider) {
|
||||
'runware' => app(RunwareAiImageProvider::class),
|
||||
default => app(NullAiImageProvider::class),
|
||||
};
|
||||
}
|
||||
}
|
||||
118
app/Services/AiEditing/AiProviderResult.php
Normal file
118
app/Services/AiEditing/AiProviderResult.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
class AiProviderResult
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $outputs
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly ?string $providerTaskId = null,
|
||||
public readonly ?string $failureCode = null,
|
||||
public readonly ?string $failureMessage = null,
|
||||
public readonly ?string $safetyState = null,
|
||||
public readonly array $safetyReasons = [],
|
||||
public readonly ?float $costUsd = null,
|
||||
public readonly array $outputs = [],
|
||||
public readonly array $requestPayload = [],
|
||||
public readonly array $responsePayload = [],
|
||||
public readonly ?int $httpStatus = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $outputs
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function succeeded(
|
||||
array $outputs = [],
|
||||
?float $costUsd = null,
|
||||
?string $safetyState = null,
|
||||
array $safetyReasons = [],
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'succeeded',
|
||||
outputs: $outputs,
|
||||
costUsd: $costUsd,
|
||||
safetyState: $safetyState,
|
||||
safetyReasons: $safetyReasons,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function processing(
|
||||
string $providerTaskId,
|
||||
?float $costUsd = null,
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'processing',
|
||||
providerTaskId: $providerTaskId,
|
||||
costUsd: $costUsd,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function failed(
|
||||
string $failureCode,
|
||||
string $failureMessage,
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'failed',
|
||||
failureCode: $failureCode,
|
||||
failureMessage: $failureMessage,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function blocked(
|
||||
string $failureCode,
|
||||
string $failureMessage,
|
||||
?string $safetyState = null,
|
||||
array $safetyReasons = [],
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'blocked',
|
||||
failureCode: $failureCode,
|
||||
failureMessage: $failureMessage,
|
||||
safetyState: $safetyState,
|
||||
safetyReasons: $safetyReasons,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
134
app/Services/AiEditing/AiStyleAccessService.php
Normal file
134
app/Services/AiEditing/AiStyleAccessService.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AiStyleAccessService
|
||||
{
|
||||
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
|
||||
|
||||
public function canUseStyle(Event $event, AiStyle $style): bool
|
||||
{
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedSources = $this->allowedSources($style);
|
||||
if (! in_array((string) $entitlement['granted_by'], $allowedSources, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requiredPackageFeatures = $this->requiredPackageFeatures($style);
|
||||
if ($requiredPackageFeatures === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$packageFeatures = $this->resolveEventPackageFeatures($event);
|
||||
|
||||
foreach ($requiredPackageFeatures as $requiredFeature) {
|
||||
if (! in_array($requiredFeature, $packageFeatures, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, AiStyle> $styles
|
||||
* @return Collection<int, AiStyle>
|
||||
*/
|
||||
public function filterStylesForEvent(Event $event, Collection $styles): Collection
|
||||
{
|
||||
return $styles
|
||||
->filter(fn (AiStyle $style): bool => $this->canUseStyle($event, $style))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedSources(AiStyle $style): array
|
||||
{
|
||||
$metadataSources = $this->normalizeStringList(Arr::get($style->metadata ?? [], 'entitlements.allowed_sources', []));
|
||||
if ($metadataSources !== []) {
|
||||
return $metadataSources;
|
||||
}
|
||||
|
||||
if (is_bool(Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon'))) {
|
||||
return Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon')
|
||||
? ['package', 'addon']
|
||||
: ['package'];
|
||||
}
|
||||
|
||||
return $style->is_premium ? ['package'] : ['package', 'addon'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function requiredPackageFeatures(AiStyle $style): array
|
||||
{
|
||||
return $this->normalizeStringList(
|
||||
Arr::get($style->metadata ?? [], 'entitlements.required_package_features', [])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveEventPackageFeatures(Event $event): array
|
||||
{
|
||||
$eventPackage = $event->relationLoaded('eventPackage') && $event->eventPackage
|
||||
? $event->eventPackage
|
||||
: $event->eventPackage()->with('package')->first();
|
||||
|
||||
if (! $eventPackage instanceof EventPackage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$package = $eventPackage->relationLoaded('package') ? $eventPackage->package : $eventPackage->package()->first();
|
||||
|
||||
return $this->normalizeFeatureList($package?->features);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeFeatureList(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$value = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return $this->normalizeStringList($value);
|
||||
}
|
||||
|
||||
return $this->normalizeStringList(array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeStringList(array $values): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => trim((string) $value),
|
||||
$values
|
||||
))));
|
||||
}
|
||||
}
|
||||
209
app/Services/AiEditing/AiStylingEntitlementService.php
Normal file
209
app/Services/AiEditing/AiStylingEntitlementService.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AiStylingEntitlementService
|
||||
{
|
||||
public function packageFeatureKey(): string
|
||||
{
|
||||
$featureKey = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling'));
|
||||
|
||||
return $featureKey !== '' ? $featureKey : 'ai_styling';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function addonKeys(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $key): string => trim((string) $key),
|
||||
(array) config('ai-editing.entitlements.addon_keys', ['ai_styling_unlock'])
|
||||
)));
|
||||
}
|
||||
|
||||
public function lockedMessage(): string
|
||||
{
|
||||
$message = trim((string) config('ai-editing.entitlements.locked_message', ''));
|
||||
|
||||
if ($message !== '') {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return 'AI editing requires the Premium package or the AI Styling add-on.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* allowed: bool,
|
||||
* granted_by: 'package'|'addon'|null,
|
||||
* required_feature: string,
|
||||
* addon_keys: array<int, string>
|
||||
* }
|
||||
*/
|
||||
public function resolveForEvent(Event $event): array
|
||||
{
|
||||
$requiredFeature = $this->packageFeatureKey();
|
||||
$addonKeys = $this->addonKeys();
|
||||
$eventPackage = $this->resolveEventPackage($event);
|
||||
|
||||
if (! $eventPackage) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'granted_by' => null,
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->packageGrantsAccess($eventPackage->package, $requiredFeature)) {
|
||||
return [
|
||||
'allowed' => true,
|
||||
'granted_by' => 'package',
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->addonGrantsAccess($eventPackage, $addonKeys, $requiredFeature)) {
|
||||
return [
|
||||
'allowed' => true,
|
||||
'granted_by' => 'addon',
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
'granted_by' => null,
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
public function hasAccessForEvent(Event $event): bool
|
||||
{
|
||||
return (bool) $this->resolveForEvent($event)['allowed'];
|
||||
}
|
||||
|
||||
private function resolveEventPackage(Event $event): ?EventPackage
|
||||
{
|
||||
$event->loadMissing('eventPackage.package', 'eventPackage.addons');
|
||||
|
||||
if ($event->eventPackage) {
|
||||
return $event->eventPackage;
|
||||
}
|
||||
|
||||
return $event->eventPackages()
|
||||
->with(['package', 'addons'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function packageGrantsAccess(?Package $package, string $requiredFeature): bool
|
||||
{
|
||||
if (! $package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $addonKeys
|
||||
*/
|
||||
private function addonGrantsAccess(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool
|
||||
{
|
||||
$addons = $eventPackage->relationLoaded('addons')
|
||||
? $eventPackage->addons
|
||||
: $eventPackage->addons()
|
||||
->where('status', 'completed')
|
||||
->get();
|
||||
|
||||
return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool {
|
||||
if (! $this->addonIsActive($addon)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$metadataFeatures = $this->normalizeFeatureList(
|
||||
Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', []))
|
||||
);
|
||||
|
||||
return in_array($requiredFeature, $metadataFeatures, true);
|
||||
});
|
||||
}
|
||||
|
||||
private function addonIsActive(EventPackageAddon $addon): bool
|
||||
{
|
||||
if ($addon->status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expiryCandidates = [
|
||||
Arr::get($addon->metadata ?? [], 'entitlements.expires_at'),
|
||||
Arr::get($addon->metadata ?? [], 'expires_at'),
|
||||
Arr::get($addon->metadata ?? [], 'valid_until'),
|
||||
];
|
||||
|
||||
foreach ($expiryCandidates as $candidate) {
|
||||
if (! is_string($candidate) || trim($candidate) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$expiresAt = CarbonImmutable::parse($candidate);
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($expiresAt->isPast()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeFeatureList(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$value = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $feature): string => trim((string) $feature),
|
||||
$value
|
||||
)));
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $feature): string => trim((string) $feature),
|
||||
array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled))
|
||||
)));
|
||||
}
|
||||
}
|
||||
67
app/Services/AiEditing/AiUsageLedgerService.php
Normal file
67
app/Services/AiEditing/AiUsageLedgerService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AiUsageLedgerService
|
||||
{
|
||||
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function recordDebitForRequest(AiEditRequest $request, ?float $costUsd = null, array $metadata = []): AiUsageLedger
|
||||
{
|
||||
return DB::transaction(function () use ($request, $costUsd, $metadata): AiUsageLedger {
|
||||
$lockedRequest = AiEditRequest::query()
|
||||
->whereKey($request->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$existing = AiUsageLedger::query()
|
||||
->where('request_id', $lockedRequest->id)
|
||||
->where('entry_type', AiUsageLedger::TYPE_DEBIT)
|
||||
->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$resolvedCost = $costUsd;
|
||||
if ($resolvedCost === null || $resolvedCost < 0) {
|
||||
$resolvedCost = (float) config('ai-editing.billing.default_unit_cost_usd', 0.01);
|
||||
}
|
||||
|
||||
$event = Event::query()->find($lockedRequest->event_id);
|
||||
$entitlement = $event ? $this->entitlements->resolveForEvent($event) : [
|
||||
'allowed' => false,
|
||||
'granted_by' => null,
|
||||
];
|
||||
|
||||
$packageContext = $entitlement['granted_by'] === 'package'
|
||||
? 'package_included'
|
||||
: ($entitlement['granted_by'] === 'addon' ? 'addon_unlock' : 'unentitled');
|
||||
|
||||
return AiUsageLedger::query()->create([
|
||||
'tenant_id' => $lockedRequest->tenant_id,
|
||||
'event_id' => $lockedRequest->event_id,
|
||||
'request_id' => $lockedRequest->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => $resolvedCost,
|
||||
'amount_usd' => $resolvedCost,
|
||||
'currency' => 'USD',
|
||||
'package_context' => $packageContext,
|
||||
'recorded_at' => now(),
|
||||
'metadata' => array_merge([
|
||||
'provider' => $lockedRequest->provider,
|
||||
'provider_model' => $lockedRequest->provider_model,
|
||||
'granted_by' => $entitlement['granted_by'],
|
||||
], $metadata),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
13
app/Services/AiEditing/Contracts/AiImageProvider.php
Normal file
13
app/Services/AiEditing/Contracts/AiImageProvider.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Contracts;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
|
||||
interface AiImageProvider
|
||||
{
|
||||
public function submit(AiEditRequest $request): AiProviderResult;
|
||||
|
||||
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult;
|
||||
}
|
||||
90
app/Services/AiEditing/EventAiEditingPolicyService.php
Normal file
90
app/Services/AiEditing/EventAiEditingPolicyService.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventAiEditingPolicyService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* enabled: bool,
|
||||
* allow_custom_prompt: bool,
|
||||
* allowed_style_keys: array<int, string>,
|
||||
* policy_message: ?string
|
||||
* }
|
||||
*/
|
||||
public function resolve(Event $event): array
|
||||
{
|
||||
$settings = is_array($event->settings) ? $event->settings : [];
|
||||
$aiSettings = Arr::get($settings, 'ai_editing', []);
|
||||
$aiSettings = is_array($aiSettings) ? $aiSettings : [];
|
||||
|
||||
$enabled = array_key_exists('enabled', $aiSettings)
|
||||
? (bool) $aiSettings['enabled']
|
||||
: true;
|
||||
$allowCustomPrompt = array_key_exists('allow_custom_prompt', $aiSettings)
|
||||
? (bool) $aiSettings['allow_custom_prompt']
|
||||
: true;
|
||||
|
||||
$allowedStyleKeys = collect($aiSettings['allowed_style_keys'] ?? [])
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $value): string => trim($value))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$policyMessage = trim((string) ($aiSettings['policy_message'] ?? ''));
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'allow_custom_prompt' => $allowCustomPrompt,
|
||||
'allowed_style_keys' => $allowedStyleKeys,
|
||||
'policy_message' => $policyMessage !== '' ? $policyMessage : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(Event $event): bool
|
||||
{
|
||||
return (bool) $this->resolve($event)['enabled'];
|
||||
}
|
||||
|
||||
public function isStyleAllowed(Event $event, ?AiStyle $style): bool
|
||||
{
|
||||
$policy = $this->resolve($event);
|
||||
|
||||
if ($style === null) {
|
||||
return (bool) $policy['allow_custom_prompt'];
|
||||
}
|
||||
|
||||
/** @var array<int, string> $allowedStyleKeys */
|
||||
$allowedStyleKeys = $policy['allowed_style_keys'];
|
||||
if ($allowedStyleKeys === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($style->key, $allowedStyleKeys, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, AiStyle> $styles
|
||||
* @return Collection<int, AiStyle>
|
||||
*/
|
||||
public function filterStyles(Event $event, Collection $styles): Collection
|
||||
{
|
||||
$policy = $this->resolve($event);
|
||||
|
||||
/** @var array<int, string> $allowedStyleKeys */
|
||||
$allowedStyleKeys = $policy['allowed_style_keys'];
|
||||
if ($allowedStyleKeys === []) {
|
||||
return $styles->values();
|
||||
}
|
||||
|
||||
return $styles
|
||||
->filter(fn (AiStyle $style): bool => in_array($style->key, $allowedStyleKeys, true))
|
||||
->values();
|
||||
}
|
||||
}
|
||||
26
app/Services/AiEditing/Providers/NullAiImageProvider.php
Normal file
26
app/Services/AiEditing/Providers/NullAiImageProvider.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Providers;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\Contracts\AiImageProvider;
|
||||
|
||||
class NullAiImageProvider implements AiImageProvider
|
||||
{
|
||||
public function submit(AiEditRequest $request): AiProviderResult
|
||||
{
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_supported',
|
||||
sprintf('The AI provider "%s" is not supported.', $request->provider)
|
||||
);
|
||||
}
|
||||
|
||||
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult
|
||||
{
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_supported',
|
||||
sprintf('The AI provider "%s" is not supported.', $request->provider)
|
||||
);
|
||||
}
|
||||
}
|
||||
287
app/Services/AiEditing/Providers/RunwareAiImageProvider.php
Normal file
287
app/Services/AiEditing/Providers/RunwareAiImageProvider.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Providers;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\Contracts\AiImageProvider;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class RunwareAiImageProvider implements AiImageProvider
|
||||
{
|
||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
|
||||
|
||||
public function submit(AiEditRequest $request): AiProviderResult
|
||||
{
|
||||
if ($this->isFakeMode()) {
|
||||
return $this->fakeResult($request);
|
||||
}
|
||||
|
||||
$apiKey = $this->apiKey();
|
||||
if (! $apiKey) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_configured',
|
||||
'Runware API key is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
[
|
||||
'taskType' => 'imageInference',
|
||||
'taskUUID' => (string) Str::uuid(),
|
||||
'positivePrompt' => (string) ($request->prompt ?? ''),
|
||||
'negativePrompt' => (string) ($request->negative_prompt ?? ''),
|
||||
'outputType' => 'URL',
|
||||
'outputFormat' => 'JPG',
|
||||
'includeCost' => true,
|
||||
'safety' => [
|
||||
'checkContent' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (is_string($request->provider_model) && $request->provider_model !== '') {
|
||||
$payload[0]['model'] = $request->provider_model;
|
||||
}
|
||||
|
||||
if (is_string($request->input_image_path) && $request->input_image_path !== '') {
|
||||
$payload[0]['seedImage'] = $request->input_image_path;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken($apiKey)
|
||||
->acceptJson()
|
||||
->timeout((int) config('services.runware.timeout', 90))
|
||||
->post($this->baseUrl(), $payload);
|
||||
|
||||
$body = (array) $response->json();
|
||||
$data = Arr::first((array) ($body['data'] ?? []), []);
|
||||
$providerTaskId = (string) ($data['taskUUID'] ?? '');
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
|
||||
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
|
||||
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
|
||||
|
||||
if (is_string($imageUrl) && $imageUrl !== '') {
|
||||
if ($providerNsfw) {
|
||||
return AiProviderResult::blocked(
|
||||
failureCode: 'provider_nsfw_content',
|
||||
failureMessage: 'Provider flagged generated content as unsafe.',
|
||||
safetyState: 'blocked',
|
||||
safetyReasons: ['provider_nsfw_content'],
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::succeeded(
|
||||
outputs: [[
|
||||
'provider_url' => $imageUrl,
|
||||
'provider_asset_id' => $providerTaskId !== '' ? $providerTaskId : null,
|
||||
'mime_type' => 'image/jpeg',
|
||||
]],
|
||||
costUsd: $cost,
|
||||
safetyState: 'passed',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($providerTaskId !== '' || $status === 'processing') {
|
||||
return AiProviderResult::processing(
|
||||
providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(),
|
||||
costUsd: $cost,
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::failed(
|
||||
'provider_unexpected_response',
|
||||
'Runware returned an unexpected response format.',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_exception',
|
||||
$exception->getMessage(),
|
||||
requestPayload: ['tasks' => $payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult
|
||||
{
|
||||
if ($this->isFakeMode()) {
|
||||
return $this->fakeResult($request, $providerTaskId);
|
||||
}
|
||||
|
||||
$apiKey = $this->apiKey();
|
||||
if (! $apiKey) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_configured',
|
||||
'Runware API key is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
$payload = [[
|
||||
'taskType' => 'getResponse',
|
||||
'taskUUID' => $providerTaskId,
|
||||
'includeCost' => true,
|
||||
]];
|
||||
|
||||
try {
|
||||
$response = Http::withToken($apiKey)
|
||||
->acceptJson()
|
||||
->timeout((int) config('services.runware.timeout', 90))
|
||||
->post($this->baseUrl(), $payload);
|
||||
|
||||
$body = (array) $response->json();
|
||||
$data = Arr::first((array) ($body['data'] ?? []), []);
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
|
||||
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
|
||||
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
|
||||
|
||||
if (is_string($imageUrl) && $imageUrl !== '') {
|
||||
if ($providerNsfw) {
|
||||
return AiProviderResult::blocked(
|
||||
failureCode: 'provider_nsfw_content',
|
||||
failureMessage: 'Provider flagged generated content as unsafe.',
|
||||
safetyState: 'blocked',
|
||||
safetyReasons: ['provider_nsfw_content'],
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::succeeded(
|
||||
outputs: [[
|
||||
'provider_url' => $imageUrl,
|
||||
'provider_asset_id' => $providerTaskId,
|
||||
'mime_type' => 'image/jpeg',
|
||||
]],
|
||||
costUsd: $cost,
|
||||
safetyState: 'passed',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 'processing' || $status === '') {
|
||||
return AiProviderResult::processing(
|
||||
providerTaskId: $providerTaskId,
|
||||
costUsd: $cost,
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($status, ['failed', 'error'], true)) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_failed',
|
||||
(string) ($data['errorMessage'] ?? 'Runware reported a failed job.'),
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::failed(
|
||||
'provider_unexpected_response',
|
||||
'Runware returned an unexpected poll response format.',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_exception',
|
||||
$exception->getMessage(),
|
||||
requestPayload: ['tasks' => $payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function fakeResult(AiEditRequest $request, ?string $taskId = null): AiProviderResult
|
||||
{
|
||||
$resolvedTaskId = $taskId ?: 'runware-fake-'.Str::uuid()->toString();
|
||||
$fakeNsfw = (bool) Arr::get($request->metadata ?? [], 'fake_nsfw', false);
|
||||
|
||||
return AiProviderResult::succeeded(
|
||||
outputs: [[
|
||||
'provider_url' => sprintf('https://cdn.example.invalid/ai/%s.jpg', $resolvedTaskId),
|
||||
'provider_asset_id' => $resolvedTaskId,
|
||||
'mime_type' => 'image/jpeg',
|
||||
'width' => 1024,
|
||||
'height' => 1024,
|
||||
]],
|
||||
costUsd: 0.01,
|
||||
safetyState: $fakeNsfw ? 'blocked' : 'passed',
|
||||
safetyReasons: $fakeNsfw ? ['provider_nsfw_content'] : [],
|
||||
requestPayload: [
|
||||
'prompt' => $request->prompt,
|
||||
'provider_model' => $request->provider_model,
|
||||
'task_id' => $resolvedTaskId,
|
||||
],
|
||||
responsePayload: [
|
||||
'mode' => 'fake',
|
||||
'status' => 'succeeded',
|
||||
'data' => [
|
||||
[
|
||||
'taskUUID' => $resolvedTaskId,
|
||||
'NSFWContent' => $fakeNsfw,
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function isFakeMode(): bool
|
||||
{
|
||||
return $this->runtimeConfig->runwareMode() === 'fake';
|
||||
}
|
||||
|
||||
private function apiKey(): ?string
|
||||
{
|
||||
$apiKey = config('services.runware.api_key');
|
||||
|
||||
return is_string($apiKey) && $apiKey !== '' ? $apiKey : null;
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
$base = (string) config('services.runware.base_url', 'https://api.runware.ai/v1');
|
||||
|
||||
return rtrim($base, '/');
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value === 1;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
39
app/Services/AiEditing/Safety/AiSafetyDecision.php
Normal file
39
app/Services/AiEditing/Safety/AiSafetyDecision.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Safety;
|
||||
|
||||
class AiSafetyDecision
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $reasonCodes
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly bool $blocked,
|
||||
public readonly string $state,
|
||||
public readonly array $reasonCodes = [],
|
||||
public readonly ?string $failureCode = null,
|
||||
public readonly ?string $failureMessage = null,
|
||||
) {}
|
||||
|
||||
public static function passed(): self
|
||||
{
|
||||
return new self(
|
||||
blocked: false,
|
||||
state: 'passed',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $reasonCodes
|
||||
*/
|
||||
public static function blocked(array $reasonCodes, string $failureCode, string $failureMessage): self
|
||||
{
|
||||
return new self(
|
||||
blocked: true,
|
||||
state: 'blocked',
|
||||
reasonCodes: array_values(array_unique($reasonCodes)),
|
||||
failureCode: $failureCode,
|
||||
failureMessage: $failureMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
100
app/Services/AiEditing/Safety/AiSafetyPolicyService.php
Normal file
100
app/Services/AiEditing/Safety/AiSafetyPolicyService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Safety;
|
||||
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AiSafetyPolicyService
|
||||
{
|
||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
|
||||
|
||||
public function evaluatePrompt(?string $prompt, ?string $negativePrompt): AiSafetyDecision
|
||||
{
|
||||
$blockedTerms = array_filter(array_map(
|
||||
static fn (mixed $term): string => Str::lower(trim((string) $term)),
|
||||
$this->runtimeConfig->blockedTerms()
|
||||
));
|
||||
|
||||
if ($blockedTerms === []) {
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
$fullPrompt = Str::lower(trim(sprintf('%s %s', (string) $prompt, (string) $negativePrompt)));
|
||||
if ($fullPrompt === '') {
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
foreach ($blockedTerms as $term) {
|
||||
if ($term !== '' && str_contains($fullPrompt, $term)) {
|
||||
$matched[] = 'prompt_blocked_term';
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === []) {
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: $matched,
|
||||
failureCode: 'prompt_policy_blocked',
|
||||
failureMessage: 'The provided prompt violates the AI editing safety policy.'
|
||||
);
|
||||
}
|
||||
|
||||
public function evaluateProviderOutput(AiProviderResult $result): AiSafetyDecision
|
||||
{
|
||||
if ($result->status === 'blocked') {
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_blocked'],
|
||||
failureCode: $result->failureCode ?: 'output_policy_blocked',
|
||||
failureMessage: $result->failureMessage ?: 'The generated output was blocked by safety policy.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($result->safetyState === 'blocked') {
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_nsfw_content'],
|
||||
failureCode: 'output_policy_blocked',
|
||||
failureMessage: 'The generated output was blocked by safety policy.'
|
||||
);
|
||||
}
|
||||
|
||||
$payloadItems = (array) Arr::get($result->responsePayload, 'data', []);
|
||||
foreach ($payloadItems as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->toBool(Arr::get($item, 'NSFWContent')) || $this->toBool(Arr::get($item, 'nsfwContent'))) {
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: ['provider_nsfw_content'],
|
||||
failureCode: 'output_policy_blocked',
|
||||
failureMessage: 'The generated output was blocked by safety policy.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value === 1;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user