feat: implement AI styling foundation and billing scope rework
This commit is contained in:
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,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user